From 220ec97d436a5b97fa75bf34f3928569257edf11 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 23:35:39 +0100 Subject: [PATCH 01/12] feat(status-pages): draft pages, create with components and publish in one step, and a read-only preview endpoint POST /v2/status_pages takes the page's components and a publish flag and writes them in one transaction; publish: false keeps the page a draft the publisher never writes until it is published. POST /v2/status_pages/preview builds the snapshot a draft config would publish from the org's real monitor history, writing nothing. --- apps/ai/src/mcp/tools/status-pages.ts | 21 +- .../src/routes/v2/status-pages.http.test.ts | 182 +- apps/api/src/routes/v2/status-pages.http.ts | 53 +- apps/api/src/routes/v2/v2-test-support.ts | 3 +- apps/api/src/runtime/service-graph.ts | 3 + docs/api-v2.md | 70 +- .../status-pages/StatusPagePublisher.test.ts | 34 + .../status-pages/StatusPagePublisher.ts | 9 +- .../StatusPageSnapshotBuilder.test.ts | 149 +- .../status-pages/StatusPageSnapshotBuilder.ts | 192 +- .../status-pages/StatusPagesService.test.ts | 84 + .../status-pages/StatusPagesService.ts | 77 +- .../src/services/status-pages/monitors.ts | 30 + .../status-pages/status-page-test-support.ts | 18 +- .../migration.sql | 2 + .../snapshot.json | 17914 ++++++++++++++++ packages/db/src/tables/status-pages.ts | 2 + packages/domain/src/http/status-pages.ts | 2 + packages/domain/src/http/v2/auth.ts | 1 + packages/domain/src/http/v2/openapi.test.ts | 1 + packages/domain/src/http/v2/status-pages.ts | 132 +- .../domain/src/mcp-outputs/status-pages.ts | 2 + 22 files changed, 18843 insertions(+), 138 deletions(-) create mode 100644 packages/backend/src/services/status-pages/monitors.ts create mode 100644 packages/db/drizzle/20261029195537_status_page_draft/migration.sql create mode 100644 packages/db/drizzle/20261029195537_status_page_draft/snapshot.json diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index a3e823e633..b8d4e10baa 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -91,6 +91,7 @@ const toPageRow = (page: StatusPageRecord): typeof StatusPageRow.Type => ({ name: page.name, slug: page.slug, autoIncidents: page.autoIncidents, + draft: page.draft, revision: page.revision, publishedRevision: page.publishedRevision, publishedAt: isoOrNull(page.publishedAt), @@ -270,11 +271,13 @@ export function registerStatusPageTools(server: McpToolRegistrar) { page.name, page.slug, page.id, - page.publishedAt === null - ? "not yet" - : page.publishedRevision < page.revision - ? `${page.publishedAt} (change pending)` - : page.publishedAt, + page.draft + ? "draft, not public" + : page.publishedAt === null + ? "not yet" + : page.publishedRevision < page.revision + ? `${page.publishedAt} (change pending)` + : page.publishedAt, page.lastPublishError ?? "-", ]), ), @@ -347,7 +350,8 @@ export function registerStatusPageTools(server: McpToolRegistrar) { freshness: { state: freshness._tag, updatedAt: freshness.updatedAt === null ? null : iso(freshness.updatedAt), - publishPending: page.publishedRevision < page.revision, + // A draft is never published, so nothing is pending for it. + publishPending: !page.draft && page.publishedRevision < page.revision, }, } }), @@ -378,7 +382,10 @@ export function registerStatusPageTools(server: McpToolRegistrar) { ["Slug", output.page.slug], ["Id", output.page.id], ["Overall", output.overallStatus], - ["Published", output.page.publishedAt ?? "not yet"], + [ + "Published", + output.page.draft ? "draft, not public" : (output.page.publishedAt ?? "not yet"), + ], [ "Change pending", output.freshness.publishPending ? "yes, publishes within a minute" : "no", diff --git a/apps/api/src/routes/v2/status-pages.http.test.ts b/apps/api/src/routes/v2/status-pages.http.test.ts index 4c352c1b5d..69cd936653 100644 --- a/apps/api/src/routes/v2/status-pages.http.test.ts +++ b/apps/api/src/routes/v2/status-pages.http.test.ts @@ -3,8 +3,12 @@ import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from " import { HttpRouter } from "effect/http" import { HttpApiBuilder } from "effect/http-api" import { OrgId, UserId } from "@maple/domain/http" -import { MapleApiV2 } from "@maple/domain/http/v2" -import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/platform/test-pglite" +import { MapleApiV2, UptimeMonitorPublicId } from "@maple/domain/http/v2" +import { UptimeMonitorId, type StatusPageId } from "@maple/domain/primitives" +import { utcDay } from "@maple/domain/status-page" +import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" +import { StatusPagePublisher } from "@maple/backend/services/status-pages/StatusPagePublisher" +import { StatusPagePublishDbError } from "@maple/backend/services/status-pages/publish-errors" import { Env } from "@maple/backend/platform/Env" import { ApiAuthorizationV2Live } from "@maple/backend/services/auth/ApiAuthorizationV2Live" import { AuditLogService, makeMemoryAuditLog } from "@maple/backend/services/audit/AuditLogService" @@ -52,7 +56,21 @@ const testConfig = () => }), ) -const makeHarness = () => { +/** A publisher that records the pages it was asked to publish, or fails every publish. */ +const fakePublisher = (published: Array, options: { readonly fail?: boolean } = {}) => + Layer.succeed(StatusPagePublisher, { + publish: (pageId) => + options.fail === true + ? Effect.fail(new StatusPagePublishDbError({ message: "edge down" })) + : Effect.sync(() => { + published.push(pageId) + return { pageId, revision: 1, written: true } + }), + sweep: () => Effect.die("unused"), + heartbeat: () => Effect.void, + }) + +const makeHarness = (publisher?: Layer.Layer) => { const testDb = createTestDb(createdDbs) const audit = makeMemoryAuditLog() const envLive = Env.layer.pipe(Layer.provide(testConfig())) @@ -77,6 +95,7 @@ const makeHarness = () => { Layer.provideMerge(Layer.succeed(AuditLogService, audit)), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), + Layer.provideMerge(publisher ?? Layer.empty), ) const { handler, dispose: disposeHandler } = HttpRouter.toWebHandler(routes, { disableLogger: true }) const runtime = ManagedRuntime.make(servicesLive) @@ -108,10 +127,13 @@ const makeHarness = () => { const auditEntries = (action: string) => Effect.runPromise(audit.list(ORG, { action, limit: 100, offset: 0 })) + const sql = (text: string, params: ReadonlyArray) => executeSql(testDb, text, [...params]) + return { request, bootstrapKey, auditEntries, + sql, dispose: async () => { await disposeHandler() await runtime.dispose() @@ -119,10 +141,13 @@ const makeHarness = () => { } } -const withHarness = (body: (harness: ReturnType) => Promise) => +const withHarness = ( + body: (harness: ReturnType) => Promise, + publisher?: Layer.Layer, +) => Effect.runPromise( Effect.acquireUseRelease( - Effect.sync(makeHarness), + Effect.sync(() => makeHarness(publisher)), (harness) => Effect.promise(() => body(harness)), (harness) => Effect.promise(() => harness.dispose()), ), @@ -692,4 +717,151 @@ describe("v2 status pages", () => { expect((await harness.request("GET", path, token)).body.name).toBe("Acme") })) }) + + describe("builder", () => { + const NOW_DAY = utcDay(Date.now()) + const encodeMonitor = Schema.encodeSync(UptimeMonitorPublicId) + + /** A monitor of `orgId` with its state and one day of checks; returns its `upm_` id. */ + const seedMonitor = async ( + harness: ReturnType, + orgId: OrgId, + status: "up" | "down", + ) => { + const id = crypto.randomUUID() + await harness.sql( + `INSERT INTO uptime_monitors (id, org_id, name, kind, config_json, created_by, created_at, updated_at) + VALUES ($1, $2, 'API', 'heartbeat', '{"type":"heartbeat","graceSeconds":60}', $3, now(), now())`, + [id, orgId, USER], + ) + await harness.sql( + `INSERT INTO uptime_monitor_states (monitor_id, org_id, status, recent_json, updated_at) + VALUES ($1, $2, $3, '[]', now())`, + [id, orgId, status], + ) + await harness.sql( + `INSERT INTO uptime_monitor_daily (monitor_id, day, org_id, checks, failures, down_checks, last_check_at) + VALUES ($1, $2, $3, 12, 3, 3, now())`, + [id, NOW_DAY, orgId], + ) + return encodeMonitor(UptimeMonitorId.make(id)) + } + + it("creates a page with its components, published at once or as a draft", async () => { + const published: Array = [] + await withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const monitor = await seedMonitor(harness, ORG, "up") + const live = await harness.request("POST", "/v2/status_pages", token, { + name: "Acme", + slug: "acme-built", + components: [ + { name: "API", monitor_id: monitor }, + { name: "Docs", group: "Web" }, + ], + publish: true, + }) + expect(live.status).toBe(200) + expect(live.body.draft).toBe(false) + const components = await harness.request( + "GET", + `/v2/status_pages/${live.body.id}/components`, + token, + ) + expect( + components.body.data.map((c: { name: string; monitor_id: string | null }) => [ + c.name, + c.monitor_id, + ]), + ).toEqual([ + ["API", monitor], + ["Docs", null], + ]) + expect(published).toHaveLength(1) + + const draft = await harness.request("POST", "/v2/status_pages", token, { + name: "Acme draft", + slug: "acme-built-draft", + publish: false, + }) + expect(draft.body.draft).toBe(true) + expect(published).toHaveLength(1) + const publish = await harness.request( + "POST", + `/v2/status_pages/${draft.body.id}/publish`, + token, + ) + expect(publish.body.outcome).toBe("published") + expect(published).toHaveLength(2) + expect( + (await harness.request("GET", `/v2/status_pages/${draft.body.id}`, token)).body.draft, + ).toBe(false) + }, fakePublisher(published)) + }) + + it("keeps a created page when the publish after it fails", () => + withHarness( + async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const created = await harness.request("POST", "/v2/status_pages", token, { + name: "Acme", + slug: "acme-publish-fails", + components: [{ name: "API" }], + publish: true, + }) + expect(created.status).toBe(200) + expect(created.body).toMatchObject({ draft: false, published_revision: 0 }) + expect( + (await harness.request("GET", `/v2/status_pages/${created.body.id}`, token)).status, + ).toBe(200) + }, + fakePublisher([], { fail: true }), + )) + + it("creates nothing when a component names another org's monitor", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const foreign = await seedMonitor(harness, OTHER_ORG, "up") + const refused = await harness.request("POST", "/v2/status_pages", token, { + name: "Acme", + slug: "acme-foreign", + components: [{ name: "API", monitor_id: foreign }], + }) + expect(refused.status).toBe(400) + expect(refused.body.error.message).toContain(foreign) + expect((await harness.request("GET", "/v2/status_pages", token)).body.data).toEqual([]) + })) + + it("previews a draft for a member from real uptime history, writing nothing", () => + withHarness(async (harness) => { + const { secret: member } = await harness.bootstrapKey(ORG, MEMBER) + const monitor = await seedMonitor(harness, ORG, "down") + const preview = await harness.request("POST", "/v2/status_pages/preview", member, { + name: "", + slug: "Half typed", + components: [{ name: "Docs" }, { name: "API", monitor_id: monitor }], + }) + expect(preview.status).toBe(200) + expect(preview.body.page).toMatchObject({ name: "Status page", slug: "preview" }) + expect(preview.body.revision).toBe(0) + const [docs, api] = preview.body.components + expect([docs.name, docs.status, docs.days]).toEqual(["Docs", "operational", []]) + expect([api.name, api.status]).toEqual(["API", "major_outage"]) + expect(api.days.at(-1)).toEqual({ day: NOW_DAY, checks: 12, failures: 3, downChecks: 3 }) + expect((await harness.request("GET", "/v2/status_pages", member)).body.data).toEqual([]) + })) + + it("refuses to preview another org's monitor", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const foreign = await seedMonitor(harness, OTHER_ORG, "down") + const refused = await harness.request("POST", "/v2/status_pages/preview", token, { + name: "Acme", + slug: "acme", + components: [{ name: "API", monitor_id: foreign }], + }) + expect(refused.status).toBe(400) + expect(refused.body.error.code).toBe("status_page_invalid") + })) + }) }) diff --git a/apps/api/src/routes/v2/status-pages.http.ts b/apps/api/src/routes/v2/status-pages.http.ts index 3a39d715f4..a11f19f0ae 100644 --- a/apps/api/src/routes/v2/status-pages.http.ts +++ b/apps/api/src/routes/v2/status-pages.http.ts @@ -29,12 +29,16 @@ import type { StatusPageIncidentStatus, } from "@maple/domain/primitives" import { DEFAULT_STATUS_PAGE_THEME, isActiveIncident } from "@maple/domain/status-page" -import { Effect, Option } from "effect" +import { Clock, Effect, Option } from "effect" import { auditDiff, diffAuditChanges } from "@/routes/v2/audit-changes" import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" import { timestampMs } from "@maple/backend/platform/time" import { requireAdmin } from "@maple/backend/services/auth/auth" import { StatusPagePublisher } from "@maple/backend/services/status-pages/StatusPagePublisher" +import { + StatusPageSnapshotBuilder, + toStatusPageDraft, +} from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" import { StatusPageStore } from "@maple/backend/services/status-pages/StatusPageStore" @@ -57,6 +61,7 @@ export const toV2StatusPage = (page: StatusPageRecord): V2StatusPage => ({ show_powered_by: page.showPoweredBy, auto_incidents: page.autoIncidents, break_glass_token_suffix: page.breakGlassTokenSuffix, + draft: page.draft, revision: page.revision, published_revision: page.publishedRevision, published_at: isoTimestampOrNull(page.publishedAt), @@ -146,6 +151,7 @@ const publishFailed = (message: string) => new StatusPagePersistenceError({ mess export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPages", (handlers) => Effect.gen(function* () { const service = yield* StatusPagesService + const snapshotBuilder = yield* StatusPageSnapshotBuilder /** The component, only when it is on this page; anything else reads as not found. */ const requireComponentOnPage = ( @@ -176,6 +182,19 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag return { object: "list" as const, ...page } }), ) + .handle("preview", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const nowMs = yield* Clock.currentTimeMillis + return yield* snapshotBuilder + .buildDraft(tenant.orgId, toStatusPageDraft(payload), nowMs) + .pipe( + Effect.catchTag("@maple/backend/status-pages/StatusPagePublishDbError", (error) => + Effect.fail(publishFailed(error.message)), + ), + ) + }), + ) .handle("retrieve", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context @@ -209,13 +228,40 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ...(payload.auto_incidents === undefined ? undefined : { autoIncidents: payload.auto_incidents }), + ...(payload.components === undefined + ? undefined + : { components: payload.components }), + draft: payload.publish === false, createdBy: tenant.userId, }) yield* recordHttpAudit("status_page.created", { resourceId: created.id, metadata: { name: created.name, slug: created.slug }, }) - return toV2StatusPage(created) + if (payload.publish !== true) return toV2StatusPage(created) + // The page is created either way: a failed publish leaves it queued for the minutely sweep. + const publisher = yield* Effect.serviceOption(StatusPagePublisher) + if (Option.isNone(publisher)) return toV2StatusPage(created) + yield* publisher.value.publish(created.id).pipe( + Effect.catch((error) => + Effect.logWarning("Status page publish after create failed").pipe( + Effect.annotateLogs({ + "maple.status_page.id": created.id, + "error.type": error._tag, + "error.message": error.message, + }), + ), + ), + ) + // Re-read for the publish state; a page deleted meanwhile answers as created. + const current = yield* service + .get(tenant.orgId, created.id) + .pipe( + Effect.catchTag("@maple/http/errors/StatusPageNotFoundError", () => + Effect.succeed(created), + ), + ) + return toV2StatusPage(current) }), ) .handle("update", ({ params, payload }) => @@ -293,7 +339,8 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, adminOnly("publish")) - const page = yield* service.get(tenant.orgId, params.id) + // A draft goes public first, so the publish below writes it. + const page = yield* service.makePublic(tenant.orgId, params.id) const publisher = yield* Effect.serviceOption(StatusPagePublisher) // Without edge storage bound here the minutely sweep publishes it. const outcome = Option.isNone(publisher) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index fbf8594e74..009c8c96ac 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -74,6 +74,7 @@ import { HttpV2ScrapeTargetsLive } from "./scrape-targets.http" import { HttpV2UptimeMonitorsLive } from "./uptime-monitors.http" import { HttpV2StatusPageIncidentsLive, HttpV2StatusPagesLive } from "./status-pages.http" import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" +import { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" import { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService" import { HttpV2SessionReplaysLive } from "./session-replays.http" import { HttpV2InstrumentationAuditLive } from "./setup-audit.http" @@ -187,7 +188,7 @@ const v2GroupLayersExceptOnboardingChecklist = ( HttpV2UptimeMonitorsLive.pipe(Layer.provide(uptimeMonitors)), // Real service: it needs only the Database every harness already provides. Layer.mergeAll(HttpV2StatusPagesLive, HttpV2StatusPageIncidentsLive).pipe( - Layer.provide(StatusPagesService.layer), + Layer.provide(Layer.mergeAll(StatusPagesService.layer, StatusPageSnapshotBuilder.layer)), ), HttpV2InstrumentationRecommendationsLive, HttpV2InstrumentationAuditLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index e4a0ed7cc7..b6107afcdf 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -58,6 +58,7 @@ import { ScrapeTargetsService } from "@maple/backend/services/integrations/Scrap import { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService" import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" import { StatusPagePublisher } from "@maple/backend/services/status-pages/StatusPagePublisher" +import { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" import { ChatWorkspaceService } from "@maple/backend/services/integrations/ChatWorkspaceService" import { TinybirdOrgTokenService } from "@maple/backend/services/integrations/TinybirdOrgTokenService" import { PlanetScaleWebhookQueue } from "@maple/backend/services/integrations/planetscale/PlanetScaleWebhookQueue" @@ -132,6 +133,8 @@ export const HttpServicesLive = Layer.mergeAll( ScrapeTargetsService.layer, UptimeMonitorsService.layer, StatusPagesService.layer, + // Read-only: previews of unsaved pages. + StatusPageSnapshotBuilder.layer, // Needs the `StatusPageStore` port, which only the deployed Worker binds. StatusPagePublisher.layer, PlanetScaleConnectionService.layer, diff --git a/docs/api-v2.md b/docs/api-v2.md index 57471bcfcc..295467542d 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -158,38 +158,38 @@ Stripe-style `expand[]` is deliberately omitted: responses embed the small, alwa Implemented in phases; the pilot (`api_keys`) ships first and proves every convention. -| Resource | Endpoints | Backing service | -| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `ApiKeysService` | -| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | -| `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/preview/instantiate) + Perses import | `dashboards` | -| `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `AlertsService` | -| `alerts/destinations` ✅ | CRUD + `test` | `AlertsService` | -| `alerts/incidents` ✅ | list/retrieve | `AlertsService` | -| `alerts/deliveries` ✅ | list delivery attempts | `AlertsService` | -| `error_issues` 🟡 | list/retrieve/`service_counts` ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` | -| `investigations` ✅ | list/retrieve/create/status | `InvestigationService` | -| `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue, `service_counts` aggregate + `PATCH` settings | `anomalies` | -| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `RecommendationIssueService` | -| `instrumentation/audit` ✅ | retrieve (singleton report, recomputed per request) | `SetupAuditService` | -| `onboarding/checklist` ✅ | retrieve + claim (singleton; steps verified per request, claim admin-only and idempotent) | `OnboardingChecklistService` | -| `scrape_targets` ✅ | CRUD + `probe` + `checks` | `ScrapeTargetsService` | -| `status_pages` ✅ | CRUD + `publish` + break-glass `rotate_break_glass`/`DELETE …/break_glass` + `components` (CRUD + `reorder`) + `config` (`GET`, atomic replace `PUT`) | `StatusPagesService` | -| `status_page_incidents` ✅ | CRUD (filter by `page_id`, `state`) + `POST …/updates` | `StatusPagesService` | -| `attribute_mappings` ✅ | CRUD | `IngestAttributeMappingService` | -| `ingest_rules` ✅ | CRUD + `reorder` + `stats`; writes admin-only. The gateway applies rules when `INGEST_RULES_ENABLED` is on (`docs/ingest-rules.md`) | `IngestRuleService` | -| `integrations/chat_*` ✅ | connector list (manifest + linked workspaces) + admin-only install/settings/unlink; the chat platform is a value, never a path segment | `ChatWorkspaceService` | -| `integrations/planetscale` ✅ | status + connect/organizations/`select_organization`/`metrics_token`/disconnect + databases/`webhook_config`/`query_insights`/`events` | `PlanetScaleConnectionService`, `PlanetScaleOAuthService`, `PlanetScaleService` | -| `integrations/gcp` ✅ | status (connectors + `metrics_available`) + admin-only connector create/update/delete (project, folder or organization scope) and `setup_scripts` | `GcpConnectorService` | -| `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` | -| `mobile_devices` ✅ | list (mine) / `PUT …/{token}` register-or-refresh / `DELETE …/{token}`; per user, per org; drives push fan-out | `MobileDevicesService` | -| `agent_feedback` ✅ | create/list; feedback about Maple from agents: `kind`, `agent` (type/name/model/version), `reason`, optional `impact`/`details`/`related_to` | `AgentFeedbackService` | -| `organization` 🟡 | retrieve (GET only shipped); update settings (incl. ClickHouse BYOC) + delete deferred | `organizations`, `orgClickHouseSettings` | -| `traces` ✅ | search/timeseries/breakdown + direct trace/span reads | `queryEngine`, `observability` | -| `logs` ✅ | search/timeseries/breakdown + direct log reads | `queryEngine` | -| `metrics` ✅ | catalog + timeseries/breakdown | `queryEngine` | -| `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}`, `GET /v2/services/{name}/overview` (summary + all-signal series + operations, one round-trip) | `queryEngine` | -| `service_map` ✅ | `GET /v2/service_map` | `queryEngine` | +| Resource | Endpoints | Backing service | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `ApiKeysService` | +| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | +| `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/preview/instantiate) + Perses import | `dashboards` | +| `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `AlertsService` | +| `alerts/destinations` ✅ | CRUD + `test` | `AlertsService` | +| `alerts/incidents` ✅ | list/retrieve | `AlertsService` | +| `alerts/deliveries` ✅ | list delivery attempts | `AlertsService` | +| `error_issues` 🟡 | list/retrieve/`service_counts` ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` | +| `investigations` ✅ | list/retrieve/create/status | `InvestigationService` | +| `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue, `service_counts` aggregate + `PATCH` settings | `anomalies` | +| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `RecommendationIssueService` | +| `instrumentation/audit` ✅ | retrieve (singleton report, recomputed per request) | `SetupAuditService` | +| `onboarding/checklist` ✅ | retrieve + claim (singleton; steps verified per request, claim admin-only and idempotent) | `OnboardingChecklistService` | +| `scrape_targets` ✅ | CRUD + `probe` + `checks` | `ScrapeTargetsService` | +| `status_pages` ✅ | CRUD + `publish` + `preview` + break-glass `rotate_break_glass`/`DELETE …/break_glass` + `components` (CRUD + `reorder`) + `config` (`GET`, atomic replace `PUT`) | `StatusPagesService` | +| `status_page_incidents` ✅ | CRUD (filter by `page_id`, `state`) + `POST …/updates` | `StatusPagesService` | +| `attribute_mappings` ✅ | CRUD | `IngestAttributeMappingService` | +| `ingest_rules` ✅ | CRUD + `reorder` + `stats`; writes admin-only. The gateway applies rules when `INGEST_RULES_ENABLED` is on (`docs/ingest-rules.md`) | `IngestRuleService` | +| `integrations/chat_*` ✅ | connector list (manifest + linked workspaces) + admin-only install/settings/unlink; the chat platform is a value, never a path segment | `ChatWorkspaceService` | +| `integrations/planetscale` ✅ | status + connect/organizations/`select_organization`/`metrics_token`/disconnect + databases/`webhook_config`/`query_insights`/`events` | `PlanetScaleConnectionService`, `PlanetScaleOAuthService`, `PlanetScaleService` | +| `integrations/gcp` ✅ | status (connectors + `metrics_available`) + admin-only connector create/update/delete (project, folder or organization scope) and `setup_scripts` | `GcpConnectorService` | +| `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` | +| `mobile_devices` ✅ | list (mine) / `PUT …/{token}` register-or-refresh / `DELETE …/{token}`; per user, per org; drives push fan-out | `MobileDevicesService` | +| `agent_feedback` ✅ | create/list; feedback about Maple from agents: `kind`, `agent` (type/name/model/version), `reason`, optional `impact`/`details`/`related_to` | `AgentFeedbackService` | +| `organization` 🟡 | retrieve (GET only shipped); update settings (incl. ClickHouse BYOC) + delete deferred | `organizations`, `orgClickHouseSettings` | +| `traces` ✅ | search/timeseries/breakdown + direct trace/span reads | `queryEngine`, `observability` | +| `logs` ✅ | search/timeseries/breakdown + direct log reads | `queryEngine` | +| `metrics` ✅ | catalog + timeseries/breakdown | `queryEngine` | +| `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}`, `GET /v2/services/{name}/overview` (summary + all-signal series + operations, one round-trip) | `queryEngine` | +| `service_map` ✅ | `GET /v2/service_map` | `queryEngine` | Also mounted, outside this table: `audit_log` (list), `environments`, `mcp_connections` (list, revoke; members see and revoke their own, admins everyone's), `instrumentation/signals` (retrieve), `widget_credentials` (mint/revoke), `widget_summary` (retrieve), and `share` (share-link reads). The contracts are in `packages/domain/src/http/v2/`. @@ -199,6 +199,12 @@ The long tail of ~40 query-engine endpoints (facets, infra hosts/pods/nodes/work `GET /v2/status_pages/{id}/config` returns a page's settings, theme and ordered components as one `StatusPageConfig` document (no incidents, break-glass token or publish state). Its settings include the optional footer links `privacy_url` and `terms_url` (https only, `null` for none), which `POST`/`PATCH /v2/status_pages` also accept. `PUT` the same document back to replace the page in one transaction with one revision bump: a component with an `id` is updated, one without is created, one left out is deleted, and list order is display order. Omitted optional fields take their defaults, and any invalid part (an `id` from another page, a duplicate, a foreign monitor, an unreadable theme) rejects the whole document with a 400, as does a key the document does not define (so a typo never resets a field) or more than 200 components. The read carries a `version`, a hash of the normalized document; the apply ignores that key, but `PUT …/config?if_version=` answers 409 `status_page_conflict` and writes nothing when the config's content no longer hashes to `v`. It is a content tag, not the page `revision`, so monitor status flips, reconciles and publishes (which bump `revision`) never refuse an apply; only an edit to the document does. The apply locks the page row before reading its components, and component writes take the same lock, so they serialize. The MCP tools `get_status_page_config` and `apply_status_page_config` and the editor's JSON tab use the same document, and both send the version they read. +### Status page builder: create in one step, drafts and preview + +`POST /v2/status_pages` also takes `components` (a `StatusPageDraftComponent` list: a config component without `id`, at most 200, list order is display order) and writes the page and all its components in one transaction; an invalid component, a foreign monitor or a taken slug creates nothing. `publish` picks what happens next: omitted, the page is public and the minutely sweep publishes it (what existing callers and MCP get); `true` publishes it right after the commit when edge storage is bound (a failed publish is logged, the create still succeeds and the sweep retries); `false` creates a draft. Every page carries `draft`: a draft is never written to the edge (the sweep skips it and a direct publish is a no-op) until `POST /v2/status_pages/{id}/publish` clears the flag, bumps the revision and publishes it. + +`POST /v2/status_pages/preview` takes a `StatusPagePreviewParams` body (the config document without `version`, components without `id`) and returns the `StatusPageSnapshot` that config would publish, built from the org's real `uptime_monitor_daily` rows and monitor states, with no incidents. It writes nothing and needs only `status_pages:read` (it is a read-only POST), so any member may call it. It previews what is typed so far: a blank name shows as `Status page`, an invalid slug as `preview`, an invalid or unreadable theme as the default theme and a non-https link as none, and no slug-taken or contrast check runs. A monitor outside the caller's org is a 400 `status_page_invalid`. Components get fixed synthetic ids from their index (`00000000-0000-4000-8000-000000000001` for the first) and the page id ends in `0`, with revision 0. + ### Telemetry reads Phase 2 exposes traces, logs, metrics, services, and the service map. Telemetry search and aggregation requests require explicit ISO-8601 UTC `start_time` and `end_time`. Search windows are capped at 7 days, timeseries at 31 days and 1,500 buckets, and breakdowns at 30 days. A breakdown over 24 hours requires an additional narrowing filter. Direct trace/span retrieval uses the `(OrgId, TraceId, SpanId)` sorting-key prefix so it returns the complete retained trace without a correctness-limiting timestamp window. Direct log retrieval derives its narrow partition window from the timestamp embedded in the log ID. diff --git a/packages/backend/src/services/status-pages/StatusPagePublisher.test.ts b/packages/backend/src/services/status-pages/StatusPagePublisher.test.ts index e40eba7bfb..43eadd1c2d 100644 --- a/packages/backend/src/services/status-pages/StatusPagePublisher.test.ts +++ b/packages/backend/src/services/status-pages/StatusPagePublisher.test.ts @@ -122,6 +122,22 @@ describe("StatusPagePublisher.publish", () => { ), ) + it.effect("writes nothing for a draft page", () => + withPublisher(({ testDb, store, publisher }) => + Effect.gen(function* () { + const pageId = newPageId() + yield* seedPage(testDb, { id: pageId, slug: "acme-draft", revision: 2, draft: true }) + const result = yield* publisher.publish(pageId) + assert.isFalse(result.written) + const { kv, objects } = yield* store.contents + assert.strictEqual(kv.size + objects.size, 0) + const row = yield* readPage(testDb, pageId) + assert.strictEqual(row?.published_revision, "0") + assert.isNull(row?.published_at) + }), + ), + ) + it.effect("rewrites a slug route lost at the edge even when the content is unchanged", () => withPublisher(({ testDb, store, publisher }) => Effect.gen(function* () { @@ -259,6 +275,24 @@ describe("StatusPagePublisher.sweep", () => { ), ) + it.effect("never takes a draft page, however far its revision lags", () => + withPublisher(({ testDb, store, publisher }) => + Effect.gen(function* () { + const draft = newPageId() + const live = newPageId() + yield* seedPage(testDb, { id: draft, slug: "sweep-draft", revision: 5, draft: true }) + yield* seedPage(testDb, { id: live, slug: "sweep-live", revision: 1 }) + + const result = yield* publisher.sweep(NOW) + assert.strictEqual(result.due, 1) + const { kv } = yield* store.contents + assert.isTrue(kv.has(StatusPageKeys.snapshot(live))) + assert.isFalse(kv.has(StatusPageKeys.snapshot(draft))) + assert.isFalse(kv.has(StatusPageKeys.slug("sweep-draft"))) + }), + ), + ) + it.effect("keeps going past a page that fails", () => withPublisher(({ testDb, store, publisher }) => Effect.gen(function* () { diff --git a/packages/backend/src/services/status-pages/StatusPagePublisher.ts b/packages/backend/src/services/status-pages/StatusPagePublisher.ts index 3de3a346a5..b474d2a2f3 100644 --- a/packages/backend/src/services/status-pages/StatusPagePublisher.ts +++ b/packages/backend/src/services/status-pages/StatusPagePublisher.ts @@ -21,7 +21,7 @@ const SWEEP_CONCURRENCY = 4 export interface StatusPagePublishResult { readonly pageId: StatusPageId readonly revision: number - /** False when the content matched the last publish and the edge write was skipped. */ + /** False when the content matched the last publish, or the page is a draft, and nothing was written. */ readonly written: boolean } @@ -38,6 +38,7 @@ export interface StatusPageSweepResult { export type StatusPagePublishError = StatusPageGoneError | StatusPagePublishDbError | StatusPageStoreError export interface StatusPagePublisherApi { + /** A draft page is a no-op: nothing reaches the edge until it is made public. */ readonly publish: (pageId: StatusPageId) => Effect.Effect /** * Drains edge cleanups, publishes pages whose revision lags or whose last publish is stale @@ -90,6 +91,7 @@ export class StatusPagePublisher extends Context.Service [$.id.eq(pageId)]) .limit(1), @@ -101,6 +103,10 @@ export class StatusPagePublisher extends Context.Service ({ id: $.id })) .where(($) => [ + $.draft.eq(false), PG.or( $.revision.gt($.publishedRevision), $.publishedAt.isNull(), diff --git a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts index 9d12a2d80f..c39f7f0f93 100644 --- a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts +++ b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts @@ -3,7 +3,14 @@ import { Effect, Exit, Layer } from "effect" import { OrgId, StatusPageComponentId, StatusPageIncidentId, UptimeMonitorId } from "@maple/domain/primitives" import { DEFAULT_STATUS_PAGE_THEME, utcDay } from "@maple/domain/status-page" import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" -import { StatusPageSnapshotBuilder, toSnapshot, type SnapshotInput } from "./StatusPageSnapshotBuilder" +import { + DRAFT_PAGE_ID, + draftComponentId, + StatusPageSnapshotBuilder, + toSnapshot, + toStatusPageDraft, + type SnapshotInput, +} from "./StatusPageSnapshotBuilder" import { DAY_MS, newPageId, @@ -14,6 +21,7 @@ import { seedIncident, seedMonitor, seedPage, + tableCounts, } from "./status-page-test-support" const trackedDbs: TestDb[] = [] @@ -507,3 +515,142 @@ describe("StatusPageSnapshotBuilder.build", () => { }).pipe(Effect.provide(StatusPageSnapshotBuilder.layer.pipe(Layer.provide(testDb.layer)))) }) }) + +describe("toStatusPageDraft", () => { + it("falls back for a blank name, an invalid slug, an unreadable theme and non-https links", () => { + const draft = toStatusPageDraft({ + name: " ", + slug: "Not A Slug", + website_url: "javascript:alert(1)", + privacy_url: "https://acme.example/privacy", + theme: { appearance: "light", text: "#777777", background: "#ffffff" }, + components: [{ name: " API ", group: " " }], + }) + assert.strictEqual(draft.page.name, "Status page") + assert.strictEqual(draft.page.slug, "preview") + assert.deepStrictEqual(draft.page.theme, DEFAULT_STATUS_PAGE_THEME) + assert.isNull(draft.page.websiteUrl) + assert.strictEqual(draft.page.privacyUrl, "https://acme.example/privacy") + assert.deepStrictEqual(draft.components, [ + { + name: "API", + description: null, + groupName: null, + monitorId: null, + manualStatus: null, + showUptime: true, + }, + ]) + }) + + it("keeps a valid slug and a readable theme", () => { + const draft = toStatusPageDraft({ + name: "Acme", + slug: "acme-status", + theme: { preset: "midnight", accent: "#A5B4FC" }, + components: [], + }) + assert.strictEqual(draft.page.slug, "acme-status") + assert.deepStrictEqual(draft.page.theme, { + ...DEFAULT_STATUS_PAGE_THEME, + preset: "midnight", + accent: "#a5b4fc", + }) + }) +}) + +describe("StatusPageSnapshotBuilder.buildDraft", () => { + const draftOf = (components: ReadonlyArray<{ name: string; monitor_id?: UptimeMonitorId }>) => + toStatusPageDraft({ name: "Acme", slug: "acme-preview", components }) + + it.effect("builds bars and statuses from the org's real monitor data, in request order", () => { + const testDb = createTestDb(trackedDbs) + const monitorId = UptimeMonitorId.make(crypto.randomUUID()) + return Effect.gen(function* () { + yield* seedMonitor(testDb, { id: monitorId, status: "down" }) + yield* seedDaily(testDb, { + monitorId, + day: utcDay(NOW), + checks: 10, + failures: 4, + downChecks: 4, + lastCheckAt: NOW - 30_000, + }) + yield* seedDaily(testDb, { + monitorId, + day: utcDay(NOW - DAY_MS), + checks: 8, + failures: 0, + downChecks: 0, + lastCheckAt: NOW - DAY_MS, + }) + const builder = yield* StatusPageSnapshotBuilder + const snapshot = yield* builder.buildDraft( + ORG, + draftOf([{ name: "Docs" }, { name: "API", monitor_id: monitorId }]), + NOW, + ) + assert.strictEqual(snapshot.pageId, DRAFT_PAGE_ID) + assert.strictEqual(snapshot.revision, 0) + assert.isNull(snapshot.breakGlassTokenSha256) + assert.deepStrictEqual(snapshot.incidents, []) + assert.deepStrictEqual( + snapshot.components.map((c) => [c.id, c.name, c.status, c.monitored]), + [ + [draftComponentId(0), "Docs", "operational", false], + [draftComponentId(1), "API", "major_outage", true], + ], + ) + const days = snapshot.components[1]?.days ?? [] + assert.lengthOf(days, 365) + assert.deepStrictEqual(days.at(-1), { + day: utcDay(NOW), + checks: 10, + failures: 4, + downChecks: 4, + }) + assert.deepStrictEqual(days.at(-2), { + day: utcDay(NOW - DAY_MS), + checks: 8, + failures: 0, + downChecks: 0, + }) + }).pipe(Effect.provide(StatusPageSnapshotBuilder.layer.pipe(Layer.provide(testDb.layer)))) + }) + + it.effect("refuses another org's monitor", () => { + const testDb = createTestDb(trackedDbs) + const foreign = UptimeMonitorId.make(crypto.randomUUID()) + return Effect.gen(function* () { + yield* seedMonitor(testDb, { id: foreign, status: "down", orgId: "org_somebody_else" }) + const builder = yield* StatusPageSnapshotBuilder + const error = yield* Effect.flip( + builder.buildDraft(ORG, draftOf([{ name: "API", monitor_id: foreign }]), NOW), + ) + assert.strictEqual(error._tag, "@maple/http/errors/StatusPageValidationError") + assert.include(error.message, "upm_") + }).pipe(Effect.provide(StatusPageSnapshotBuilder.layer.pipe(Layer.provide(testDb.layer)))) + }) + + it.effect("writes nothing", () => { + const testDb = createTestDb(trackedDbs) + const pageId = newPageId() + const monitorId = UptimeMonitorId.make(crypto.randomUUID()) + return Effect.gen(function* () { + yield* seedPage(testDb, { id: pageId, slug: "acme-existing", revision: 2 }) + yield* seedMonitor(testDb, { id: monitorId, status: "up" }) + yield* seedComponent(testDb, { + id: crypto.randomUUID(), + pageId, + name: "API", + position: 0, + monitorId, + }) + const before = yield* tableCounts(testDb) + const builder = yield* StatusPageSnapshotBuilder + yield* builder.buildDraft(ORG, draftOf([{ name: "API", monitor_id: monitorId }]), NOW) + assert.deepStrictEqual(yield* tableCounts(testDb), before) + assert.strictEqual((yield* builder.build(pageId, NOW)).revision, 2) + }).pipe(Effect.provide(StatusPageSnapshotBuilder.layer.pipe(Layer.provide(testDb.layer)))) + }) +}) diff --git a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts index b2b370e1fe..4161dbc015 100644 --- a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts +++ b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts @@ -1,4 +1,5 @@ import * as PG from "@maple-dev/effect-orm/postgres" +import type { MapleDb } from "@maple/db/client" import { StatusPageComponents, StatusPageIncidentComponents, @@ -9,20 +10,23 @@ import { UptimeMonitors, UptimeMonitorStates, } from "@maple/db/tables" -import type { - OrgId, +import type { StatusPageValidationError } from "@maple/domain/http" +import { normalizeStatusPageConfig, type StatusPagePreviewParams } from "@maple/domain/http/v2" +import { StatusPageComponentId, - StatusPageComponentStatus, StatusPageId, - StatusPageIncidentId, - StatusPageIncidentImpact, - StatusPageIncidentKind, - StatusPageIncidentStatus, - StatusPageUpdateSource, - UptimeMonitorId, - UptimeMonitorStatus, + type OrgId, + type StatusPageComponentStatus, + type StatusPageIncidentId, + type StatusPageIncidentImpact, + type StatusPageIncidentKind, + type StatusPageIncidentStatus, + type StatusPageUpdateSource, + type UptimeMonitorId, + type UptimeMonitorStatus, } from "@maple/domain/primitives" import { + DEFAULT_STATUS_PAGE_THEME, deriveComponentStatus, fillDays, incidentComponentStatus, @@ -31,15 +35,18 @@ import { STATUS_PAGE_HISTORY_DAYS, STATUS_PAGE_INCIDENT_HISTORY_DAYS, STATUS_PAGE_SNAPSHOT_VERSION, + StatusPageSlug, + StatusPageTheme, + themeContrastIssue, type StatusPageSnapshot, type StatusPageIncidentHistoryEntry, type StatusPageSnapshotIncident, - type StatusPageTheme, utcDay, } from "@maple/domain/status-page" -import { Context, Effect, Layer } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute, makePersistenceErrorMapper } from "@maple/backend/platform/db-execute" +import { ensureMonitorsInOrg } from "./monitors" import { StatusPageGoneError, StatusPagePublishDbError } from "./publish-errors" /** Most resolved incidents one snapshot indexes for the calendar: about one a day for a year. */ @@ -252,12 +259,102 @@ export const toSnapshot = (input: SnapshotInput): StatusPageSnapshot => { } } +/** A page that has no row yet: what the builder previews before anything is saved. */ +export interface StatusPageDraft { + readonly page: Omit + readonly components: ReadonlyArray> +} + +/** Synthetic ids for a draft: fixed UUIDs, the page's ending in 0 and component `i`'s in `i + 1`. */ +const draftUuid = (n: number) => `00000000-0000-4000-8000-${n.toString(16).padStart(12, "0")}` +export const DRAFT_PAGE_ID = StatusPageId.make(draftUuid(0)) +export const draftComponentId = (index: number) => StatusPageComponentId.make(draftUuid(index + 1)) + +const DRAFT_PLACEHOLDER_NAME = "Status page" +const DRAFT_PLACEHOLDER_SLUG = "preview" +const isSlug = Schema.is(StatusPageSlug) +const isTheme = Schema.is(StatusPageTheme) + +const httpsOrNull = (value: string | null) => + value !== null && URL.canParse(value) && new URL(value).protocol === "https:" ? value : null + +/** A config as typed so far, as a draft: blanks, an invalid slug and an unreadable theme fall back. */ +export const toStatusPageDraft = (params: StatusPagePreviewParams): StatusPageDraft => { + const config = normalizeStatusPageConfig(params) + const theme = + isTheme(config.theme) && themeContrastIssue(config.theme) === null + ? config.theme + : DEFAULT_STATUS_PAGE_THEME + return { + page: { + name: config.name.length === 0 ? DRAFT_PLACEHOLDER_NAME : config.name, + slug: isSlug(config.slug) ? config.slug : DRAFT_PLACEHOLDER_SLUG, + description: config.description, + websiteUrl: httpsOrNull(config.website_url), + logoUrl: httpsOrNull(config.logo_url), + privacyUrl: httpsOrNull(config.privacy_url), + termsUrl: httpsOrNull(config.terms_url), + theme, + showPoweredBy: config.show_powered_by, + }, + components: config.components.map((component) => ({ + name: component.name, + description: component.description, + groupName: component.group, + monitorId: component.monitor_id, + manualStatus: component.manual_status, + showUptime: component.show_uptime, + })), + } +} + +/** One org's daily rows since `firstDay` and current statuses for these monitors. Read-only. */ +const readMonitorData = Effect.fnUntraced(function* ( + db: MapleDb, + orgId: OrgId, + monitorIds: ReadonlyArray, + firstDay: string, +) { + if (monitorIds.length === 0) + return { daily: [], statuses: new Map() } + const daily = yield* db.run( + PG.from(UptimeMonitorDaily) + .select(($) => ({ + monitorId: $.monitorId, + day: $.day, + checks: $.checks, + failures: $.failures, + downChecks: $.downChecks, + })) + .where(($) => [$.orgId.eq(orgId), PG.inList($.monitorId, monitorIds), $.day.gte(firstDay)]), + ) + const states = yield* db.run( + PG.from(UptimeMonitorStates) + .select(($) => ({ monitorId: $.monitorId, status: $.status })) + .where(($) => [$.orgId.eq(orgId), PG.inList($.monitorId, monitorIds)]), + ) + return { daily, statuses: new Map(states.map((state) => [state.monitorId, state.status])) } +}) + +const uniqueMonitorIds = (components: ReadonlyArray<{ readonly monitorId: UptimeMonitorId | null }>) => [ + ...new Set(components.flatMap((c) => (c.monitorId === null ? [] : [c.monitorId]))), +] + export interface StatusPageSnapshotBuilderApi { /** Reads the page by id (a system path: every later query is scoped to its org). */ readonly build: ( pageId: StatusPageId, nowMs: number, ) => Effect.Effect + /** + * The snapshot a draft would publish, over the org's real uptime history; no incidents and no + * writes. A monitor outside the org fails validation. + */ + readonly buildDraft: ( + orgId: OrgId, + draft: StatusPageDraft, + nowMs: number, + ) => Effect.Effect /** * Newest scheduled check (the prober's pulse); null when none ran lately. Across all orgs for the * edge heartbeat (a system read), or one org's monitors when `orgId` is given. @@ -311,11 +408,8 @@ export class StatusPageSnapshotBuilder extends Context.Service< ) if (page === undefined) return null const orgId = page.orgId - const components = yield* db.run( - PG.from(StatusPageComponents, "c") - .leftJoin(UptimeMonitorStates, "s", (c, s) => - c.monitorId.eq(s.monitorId).and(c.orgId.eq(s.orgId)), - ) + const rows = yield* db.run( + PG.from(StatusPageComponents) .select(($) => ({ id: $.id, name: $.name, @@ -324,31 +418,21 @@ export class StatusPageSnapshotBuilder extends Context.Service< monitorId: $.monitorId, manualStatus: $.manualStatus, showUptime: $.showUptime, - monitorStatus: $.s.status, position: $.position, })) .where(($) => [$.orgId.eq(orgId), $.pageId.eq(pageId)]) .orderBy(["position", "asc"], ["id", "asc"]), ) - const monitorIds = components.flatMap((c) => (c.monitorId === null ? [] : [c.monitorId])) - const daily = - monitorIds.length === 0 - ? [] - : yield* db.run( - PG.from(UptimeMonitorDaily) - .select(($) => ({ - monitorId: $.monitorId, - day: $.day, - checks: $.checks, - failures: $.failures, - downChecks: $.downChecks, - })) - .where(($) => [ - $.orgId.eq(orgId), - PG.inList($.monitorId, monitorIds), - $.day.gte(firstDay), - ]), - ) + const { daily, statuses } = yield* readMonitorData( + db, + orgId, + uniqueMonitorIds(rows), + firstDay, + ) + const components = rows.map((row) => ({ + ...row, + monitorStatus: row.monitorId === null ? null : (statuses.get(row.monitorId) ?? null), + })) const incidentFields = PG.from(StatusPageIncidents).select(($) => ({ id: $.id, kind: $.kind, @@ -431,6 +515,38 @@ export class StatusPageSnapshotBuilder extends Context.Service< return toSnapshot({ ...input, nowMs }) }) + const buildDraft = Effect.fn("StatusPageSnapshotBuilder.buildDraft")(function* ( + orgId: OrgId, + draft: StatusPageDraft, + nowMs: number, + ) { + yield* Effect.annotateCurrentSpan({ + orgId, + "maple.status_page.components": draft.components.length, + }) + const firstDay = lastDays(nowMs, STATUS_PAGE_HISTORY_DAYS)[0] ?? "" + const monitorIds = uniqueMonitorIds(draft.components) + const { daily, statuses } = yield* dbExecute((db) => + ensureMonitorsInOrg(db, orgId, monitorIds).pipe( + Effect.andThen(readMonitorData(db, orgId, monitorIds, firstDay)), + ), + ) + return toSnapshot({ + page: { ...draft.page, id: DRAFT_PAGE_ID, breakGlassTokenSha256: null, revision: 0 }, + components: draft.components.map((component, index) => ({ + ...component, + id: draftComponentId(index), + monitorStatus: + component.monitorId === null ? null : (statuses.get(component.monitorId) ?? null), + })), + daily, + incidents: [], + updates: [], + incidentComponents: [], + nowMs, + }) + }) + const probesSeenAt = Effect.fn("StatusPageSnapshotBuilder.probesSeenAt")(function* ( nowMs: number, orgId?: OrgId, @@ -455,7 +571,7 @@ export class StatusPageSnapshotBuilder extends Context.Service< return seen?.at ?? null }) - return { build, probesSeenAt } + return { build, buildDraft, probesSeenAt } }), }) { static readonly layer = Layer.effect(this, this.make) diff --git a/packages/backend/src/services/status-pages/StatusPagesService.test.ts b/packages/backend/src/services/status-pages/StatusPagesService.test.ts index 0c94a83a1e..824e50d8a9 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.test.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.test.ts @@ -946,4 +946,88 @@ describe("StatusPagesService", () => { }).pipe(Effect.provide(makeLayer(testDb))) }) }) + + describe("create with components and drafts", () => { + const pageCount = (testDb: TestDb) => + Effect.promise(() => + testDb.pglite.query<{ count: number }>(`SELECT count(*)::int AS count FROM status_pages`), + ).pipe(Effect.map((result) => result.rows[0]?.count)) + + it.effect("writes the page and its components in list order, as a draft when asked", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + yield* seedMonitor(testDb, ORG, MONITOR) + const service = yield* StatusPagesService + const page = yield* service.create(ORG, { + name: "Acme", + slug: "acme-draft", + components: [ + { name: " API ", monitor_id: MONITOR, group: "Core" }, + { name: "Docs", show_uptime: false, manual_status: "degraded" }, + ], + draft: true, + createdBy: USER, + }) + assert.isTrue(page.draft) + const components = yield* service.listComponents(ORG, page.id) + assert.deepStrictEqual( + components.map((c) => [c.name, c.position, c.monitorId, c.groupName, c.showUptime]), + [ + ["API", 0, MONITOR, "Core", true], + ["Docs", 1, null, null, false], + ], + ) + const plain = yield* createPage("acme-public") + assert.isFalse(plain.draft) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("creates nothing when a component is invalid or names another org's monitor", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + yield* seedMonitor(testDb, OTHER_ORG, OTHER_MONITOR) + const service = yield* StatusPagesService + const foreign = yield* Effect.flip( + service.create(ORG, { + name: "Acme", + slug: "acme-foreign", + components: [{ name: "API" }, { name: "Stolen", monitor_id: OTHER_MONITOR }], + createdBy: USER, + }), + ) + assert.strictEqual(foreign._tag, "@maple/http/errors/StatusPageValidationError") + assert.include(foreign.message, "upm_") + const blank = yield* Effect.flip( + service.create(ORG, { + name: "Acme", + slug: "acme-blank", + components: [{ name: "API" }, { name: " " }], + createdBy: USER, + }), + ) + assert.include(blank.message, "components[1].name") + assert.strictEqual(yield* pageCount(testDb), 0) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("makePublic clears a draft once and bumps its revision only then", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const service = yield* StatusPagesService + const page = yield* service.create(ORG, { + name: "Acme", + slug: "acme-public-later", + draft: true, + createdBy: USER, + }) + const crossOrg = yield* Effect.flip(service.makePublic(OTHER_ORG, page.id)) + assert.strictEqual(crossOrg._tag, "@maple/http/errors/StatusPageNotFoundError") + const published = yield* service.makePublic(ORG, page.id) + assert.isFalse(published.draft) + assert.strictEqual(published.revision, page.revision + 1) + const again = yield* service.makePublic(ORG, page.id) + assert.strictEqual(again.revision, published.revision) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + }) }) diff --git a/packages/backend/src/services/status-pages/StatusPagesService.ts b/packages/backend/src/services/status-pages/StatusPagesService.ts index a6b2485b1a..227152d425 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.ts @@ -35,7 +35,7 @@ import { statusPageConfigVersion, type StatusPageConfig, type StatusPageConfigChangeSet, - UptimeMonitorPublicId, + type StatusPageDraftComponent, } from "@maple/domain/http/v2" import { StatusPageComponentId, @@ -66,6 +66,7 @@ import { Clock, Context, Effect, Layer, Schema } from "effect" import { Database, type DatabaseError } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute } from "@maple/backend/platform/db-execute" import { postgresSqlState } from "@maple/backend/platform/postgres-errors" +import { ensureMonitorsInOrg } from "./monitors" import { bumpPage, queuePageRemoval, queueSlugRemoval } from "./outbox" export interface CreateStatusPageInput { @@ -79,6 +80,10 @@ export interface CreateStatusPageInput { readonly theme?: StatusPageTheme readonly showPoweredBy?: boolean readonly autoIncidents?: StatusPageAutoIncidents + /** Written with the page in its transaction; list order is display order. */ + readonly components?: ReadonlyArray + /** A draft is not published until `makePublic`. */ + readonly draft?: boolean readonly createdBy: UserId } @@ -178,6 +183,8 @@ export interface StatusPagesServiceApi { patch: UpdateStatusPageInput, ) => Effect.Effect readonly remove: (orgId: OrgId, pageId: StatusPageId) => Effect.Effect + /** Clears `draft` so the publisher takes the page; a page already public is unchanged. */ + readonly makePublic: (orgId: OrgId, pageId: StatusPageId) => Effect.Effect /** Returns the new raw token; only its hash and last 6 characters are stored. */ readonly rotateBreakGlassToken: (orgId: OrgId, pageId: StatusPageId) => Effect.Effect readonly revokeBreakGlassToken: (orgId: OrgId, pageId: StatusPageId) => Effect.Effect @@ -401,6 +408,7 @@ const toPageRecord = (row: StatusPageRow): StatusPageRecord => ({ showPoweredBy: row.showPoweredBy, autoIncidents: row.autoIncidents, breakGlassTokenSuffix: row.breakGlassTokenSuffix, + draft: row.draft, revision: row.revision, publishedRevision: row.publishedRevision, publishedAt: row.publishedAt, @@ -612,25 +620,6 @@ const replaceIncidentComponents = Effect.fnUntraced(function* ( }) const encodeComponentId = Schema.encodeSync(StatusPageComponentPublicId) -const encodeMonitorId = Schema.encodeSync(UptimeMonitorPublicId) - -/** Every monitor must belong to the org; the error names the first that does not by its public id. */ -const ensureMonitorsInOrg = Effect.fnUntraced(function* ( - db: MapleDb, - orgId: OrgId, - monitorIds: ReadonlyArray, -) { - const [first, ...rest] = monitorIds - if (first === undefined) return - const found = yield* db.run( - PG.from(UptimeMonitors) - .select("id") - .where(($) => [$.orgId.eq(orgId), $.id.in_(first, ...rest)]), - ) - const known = new Set(found.map((row) => row.id)) - const missing = monitorIds.find((id) => !known.has(id)) - if (missing !== undefined) return yield* invalid(`Unknown uptime monitor ${encodeMonitorId(missing)}`) -}) const contentOf = ( page: StatusPageRow, @@ -747,12 +736,19 @@ export class StatusPagesService extends Context.Service MAX_STATUS_PAGE_COMPONENTS) return yield* tooManyComponents() + const components = yield* Effect.forEach(draftComponents, validateConfigComponent) + const monitorIds = [ + ...new Set(components.flatMap((c) => (c.monitorId === null ? [] : [c.monitorId]))), + ] const now = yield* Clock.currentTimeMillis const id = StatusPageId.make(randomUUID()) yield* dbExecuteSlug(slug)((db) => db.transaction( Effect.gen(function* () { yield* ensureSlugFree(db, slug, null) + yield* ensureMonitorsInOrg(db, orgId, monitorIds) // A new page starts at revision 1 > published 0, so the publisher picks it up. yield* db.run( PG.insertInto(StatusPages).values({ @@ -770,6 +766,7 @@ export class StatusPagesService extends Context.Service ({ + id: StatusPageComponentId.make(randomUUID()), + orgId, + pageId: id, + name: component.name, + description: component.description, + groupName: component.groupName, + position, + monitorId: component.monitorId, + manualStatus: component.manualStatus, + showUptime: component.showUptime, + createdAt: now, + updatedAt: now, + })) + if (rows.length > 0) + yield* db.run(PG.insertInto(StatusPageComponents).values(rows)) }), ), ) @@ -883,6 +896,29 @@ export class StatusPagesService extends Context.Service + db.transaction( + Effect.gen(function* () { + yield* findPage(db, orgId, pageId) + const cleared = yield* db.run( + PG.update(StatusPages) + .set({ draft: false, updatedAt: now }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(pageId), $.draft.eq(true)]) + .returning("id"), + ) + if (cleared.length > 0) yield* bumpPage(db, orgId, pageId) + }), + ), + ) + return yield* requirePage(orgId, pageId) + }) + const rotateBreakGlassToken = Effect.fn("StatusPagesService.rotateBreakGlassToken")(function* ( orgId: OrgId, pageId: StatusPageId, @@ -1597,6 +1633,7 @@ export class StatusPagesService extends Context.Service, +) { + const [first, ...rest] = monitorIds + if (first === undefined) return + const found = yield* db.run( + PG.from(UptimeMonitors) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.id.in_(first, ...rest)]), + ) + const known = new Set(found.map((row) => row.id)) + const missing = monitorIds.find((id) => !known.has(id)) + if (missing !== undefined) + return yield* new StatusPageValidationError({ + message: `Unknown uptime monitor ${encodeMonitorId(missing)}`, + }) +}) diff --git a/packages/backend/src/services/status-pages/status-page-test-support.ts b/packages/backend/src/services/status-pages/status-page-test-support.ts index ad660d2cec..255c9d9b1a 100644 --- a/packages/backend/src/services/status-pages/status-page-test-support.ts +++ b/packages/backend/src/services/status-pages/status-page-test-support.ts @@ -26,13 +26,14 @@ export const seedPage = ( readonly publishedRevision?: number readonly publishedAt?: number | null readonly publishAttemptedAt?: number | null + readonly draft?: boolean }, ) => sql( testDb, `INSERT INTO status_pages (id, org_id, name, slug, auto_incidents, break_glass_token_sha256, - revision, published_revision, published_at, created_by, created_at, updated_at, publish_attempted_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12)`, + revision, published_revision, published_at, created_by, created_at, updated_at, publish_attempted_at, draft) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12, $13)`, [ input.id, ORG, @@ -48,6 +49,7 @@ export const seedPage = ( input.publishAttemptedAt === undefined || input.publishAttemptedAt === null ? null : iso(input.publishAttemptedAt), + input.draft ?? false, ], ) @@ -235,6 +237,18 @@ export const readPage = (testDb: TestDb, pageId: string) => ), ) +/** Rows in the page, component and edge-cleanup tables, to show a read wrote nothing. */ +export const tableCounts = (testDb: TestDb) => + Effect.promise(() => + queryFirstRow<{ pages: number; components: number; cleanups: number }>( + testDb, + `SELECT (SELECT count(*) FROM status_pages)::int AS pages, + (SELECT count(*) FROM status_page_components)::int AS components, + (SELECT count(*) FROM status_page_cleanups)::int AS cleanups`, + [], + ), + ) + export const bumpRevision = (testDb: TestDb, pageId: string) => sql(testDb, `UPDATE status_pages SET revision = revision + 1 WHERE id = $1`, [pageId]) diff --git a/packages/db/drizzle/20261029195537_status_page_draft/migration.sql b/packages/db/drizzle/20261029195537_status_page_draft/migration.sql new file mode 100644 index 0000000000..68fbce7997 --- /dev/null +++ b/packages/db/drizzle/20261029195537_status_page_draft/migration.sql @@ -0,0 +1,2 @@ +-- metadata +ALTER TABLE "status_pages" ADD COLUMN IF NOT EXISTS "draft" boolean NOT NULL DEFAULT false; diff --git a/packages/db/drizzle/20261029195537_status_page_draft/snapshot.json b/packages/db/drizzle/20261029195537_status_page_draft/snapshot.json new file mode 100644 index 0000000000..fa6e1bd9a3 --- /dev/null +++ b/packages/db/drizzle/20261029195537_status_page_draft/snapshot.json @@ -0,0 +1,17914 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "91231b323baa55b99ed327dea75ebd76b249a272a8b983b2406360e82d8ce80d", + "prevIds": [ + "82814612a3f6ed20ab1a4ac7410a9d0f5c90a9d8735f6bacec6235c16e382509" + ], + "entities": [ + { + "kind": "table", + "name": "actors", + "primaryKey": { + "name": "actors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "agent_feedback", + "primaryKey": { + "name": "agent_feedback_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "ai_triage_settings", + "primaryKey": { + "name": "ai_triage_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "alert_delivery_events", + "primaryKey": { + "name": "alert_delivery_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_destinations", + "primaryKey": { + "name": "alert_destinations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_incidents", + "primaryKey": { + "name": "alert_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_linear_issues", + "primaryKey": { + "name": "alert_linear_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_rule_claims", + "primaryKey": { + "name": "alert_rule_claims_pkey", + "columns": [ + "rule_id" + ] + } + }, + { + "kind": "table", + "name": "alert_rule_states", + "primaryKey": { + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "columns": [ + "org_id", + "rule_id", + "group_key" + ] + } + }, + { + "kind": "table", + "name": "alert_rules", + "primaryKey": { + "name": "alert_rules_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_settings", + "primaryKey": { + "name": "anomaly_detector_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_states", + "primaryKey": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + { + "kind": "table", + "name": "anomaly_incidents", + "primaryKey": { + "name": "anomaly_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "api_keys", + "primaryKey": { + "name": "api_keys_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "aws_connections", + "primaryKey": { + "name": "aws_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cancellation_reviews", + "primaryKey": { + "name": "cancellation_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_identities", + "primaryKey": { + "name": "chat_identities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_workspaces", + "primaryKey": { + "name": "chat_workspaces_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cli_device_authorizations", + "primaryKey": { + "name": "cli_device_authorizations_pkey", + "columns": [ + "device_code_hash" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_analytics_state", + "primaryKey": { + "name": "cloudflare_analytics_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_hyperdrive_configs", + "primaryKey": { + "name": "cloudflare_hyperdrive_configs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_logpush_connectors", + "primaryKey": { + "name": "cloudflare_logpush_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_shares", + "primaryKey": { + "name": "dashboard_shares_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_versions", + "primaryKey": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboards", + "primaryKey": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "device_credentials", + "primaryKey": { + "name": "device_credentials_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "device_proof_replays", + "primaryKey": { + "name": "device_proof_replays_pkey", + "columns": [ + "key" + ] + } + }, + { + "kind": "table", + "name": "digest_subscriptions", + "primaryKey": { + "name": "digest_subscriptions_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_fingerprint_candidates", + "primaryKey": { + "name": "error_fingerprint_candidates_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + { + "kind": "table", + "name": "error_incidents", + "primaryKey": { + "name": "error_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_events", + "primaryKey": { + "name": "error_issue_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_pull_requests", + "primaryKey": { + "name": "error_issue_pull_requests_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_states", + "primaryKey": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_verifications", + "primaryKey": { + "name": "error_issue_verifications_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issues", + "primaryKey": { + "name": "error_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_deliveries", + "primaryKey": { + "name": "error_notification_deliveries_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_policies", + "primaryKey": { + "name": "error_notification_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "error_tick_states", + "primaryKey": { + "name": "error_tick_states_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "gcp_connectors", + "primaryKey": { + "name": "gcp_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "gcp_resources", + "primaryKey": { + "name": "gcp_resources_pkey", + "columns": [ + "connector_id", + "name" + ] + } + }, + { + "kind": "table", + "name": "investigations", + "primaryKey": { + "name": "investigations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "issue_escalation_policies", + "primaryKey": { + "name": "issue_escalation_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "issue_escalations", + "primaryKey": { + "name": "issue_escalations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "live_activities", + "primaryKey": { + "name": "live_activities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_authorizations", + "primaryKey": { + "name": "mcp_oauth_authorizations_pkey", + "columns": [ + "request_id_hash" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_clients", + "primaryKey": { + "name": "mcp_oauth_clients_pkey", + "columns": [ + "client_id" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_refresh_tokens", + "primaryKey": { + "name": "mcp_oauth_refresh_tokens_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "mobile_devices", + "primaryKey": { + "name": "mobile_devices_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "oauth_auth_states", + "primaryKey": { + "name": "oauth_auth_states_pkey", + "columns": [ + "state" + ] + } + }, + { + "kind": "table", + "name": "oauth_connections", + "primaryKey": { + "name": "oauth_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_clickhouse_schema_apply_runs", + "primaryKey": { + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_clickhouse_settings", + "primaryKey": { + "name": "org_clickhouse_settings_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_attribute_mappings", + "primaryKey": { + "name": "org_ingest_attribute_mappings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_keys", + "primaryKey": { + "name": "org_ingest_keys_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_rule_settings", + "primaryKey": { + "name": "org_ingest_rule_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_rule_stats", + "primaryKey": { + "name": "org_ingest_rule_stats_pkey", + "columns": [ + "org_id", + "rule_id", + "bucket", + "signal" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_rules", + "primaryKey": { + "name": "org_ingest_rules_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_sampling_policies", + "primaryKey": { + "name": "org_ingest_sampling_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_onboarding_state", + "primaryKey": { + "name": "org_onboarding_state_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_openrouter_keys", + "primaryKey": { + "name": "org_openrouter_keys_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_recommendation_issues", + "primaryKey": { + "name": "org_recommendation_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_support_channels", + "primaryKey": { + "name": "org_support_channels_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_connections", + "primaryKey": { + "name": "planetscale_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_databases", + "primaryKey": { + "name": "planetscale_databases_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_events", + "primaryKey": { + "name": "planetscale_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_issue_receipts", + "primaryKey": { + "name": "planetscale_issue_receipts_org_id_event_id_pk", + "columns": [ + "org_id", + "event_id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_poll_state", + "primaryKey": { + "name": "planetscale_poll_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_edits", + "primaryKey": { + "name": "pr_review_edits_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_finding_embeddings", + "primaryKey": { + "name": "pr_review_finding_embeddings_pkey", + "columns": [ + "finding_id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_findings", + "primaryKey": { + "name": "pr_review_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_merge_steps", + "primaryKey": { + "name": "pr_review_merge_steps_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_replies", + "primaryKey": { + "name": "pr_review_replies_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_settings", + "primaryKey": { + "name": "pr_review_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "pr_reviews", + "primaryKey": { + "name": "pr_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "railway_connections", + "primaryKey": { + "name": "railway_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "railway_environments", + "primaryKey": { + "name": "railway_environments_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "scrape_target_checks", + "primaryKey": { + "name": "scrape_target_checks_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "scrape_targets", + "primaryKey": { + "name": "scrape_targets_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_events", + "primaryKey": { + "name": "security_audit_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_findings", + "primaryKey": { + "name": "security_audit_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_org_reservations", + "primaryKey": { + "name": "security_audit_org_reservations_pkey", + "columns": [ + "run_id", + "agent_id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_org_settings", + "primaryKey": { + "name": "security_audit_org_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_run_findings", + "primaryKey": { + "name": "security_audit_run_findings_pkey", + "columns": [ + "run_id", + "fingerprint" + ] + } + }, + { + "kind": "table", + "name": "security_audit_runs", + "primaryKey": { + "name": "security_audit_runs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_targets", + "primaryKey": { + "name": "security_audit_targets_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_units", + "primaryKey": { + "name": "security_audit_units_pkey", + "columns": [ + "run_id", + "coverage_id" + ] + } + }, + { + "kind": "table", + "name": "sources", + "primaryKey": { + "name": "sources_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_cleanups", + "primaryKey": { + "name": "status_page_cleanups_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_components", + "primaryKey": { + "name": "status_page_components_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_incident_components", + "primaryKey": { + "name": "status_page_incident_components_pk", + "columns": [ + "incident_id", + "component_id" + ] + } + }, + { + "kind": "table", + "name": "status_page_incident_updates", + "primaryKey": { + "name": "status_page_incident_updates_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_incidents", + "primaryKey": { + "name": "status_page_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_pages", + "primaryKey": { + "name": "status_pages_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "supabase_connections", + "primaryKey": { + "name": "supabase_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "supabase_project_insights", + "primaryKey": { + "name": "supabase_project_insights_pkey", + "columns": [ + "project_id" + ] + } + }, + { + "kind": "table", + "name": "supabase_projects", + "primaryKey": { + "name": "supabase_projects_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "uptime_monitor_daily", + "primaryKey": { + "name": "uptime_monitor_daily_pk", + "columns": [ + "monitor_id", + "day" + ] + } + }, + { + "kind": "table", + "name": "uptime_monitor_states", + "primaryKey": { + "name": "uptime_monitor_states_pkey", + "columns": [ + "monitor_id" + ] + } + }, + { + "kind": "table", + "name": "uptime_monitors", + "primaryKey": { + "name": "uptime_monitors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_commits", + "primaryKey": { + "name": "vcs_commits_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_installations", + "primaryKey": { + "name": "vcs_installations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_repositories", + "primaryKey": { + "name": "vcs_repositories_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_repository_branches", + "primaryKey": { + "name": "vcs_repository_branches_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "column", + "table": "actors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "user_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "agent_name", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "model", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "capabilities_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "created_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "last_active_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "impact", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "summary", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "reason", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "details", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "related_to", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_type", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_name", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_model", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_version", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "max_runs_per_day", + "position": 2, + "type": "integer", + "notNull": true, + "default": "20", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "max_passes_per_day", + "position": 3, + "type": "integer", + "notNull": true, + "default": "90", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "updated_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "updated_by", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "rule_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "destination_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "delivery_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "event_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "attempt_number", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "scheduled_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claimed_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claim_expires_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claimed_by", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "attempted_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "provider_message", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "provider_reference", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "response_code", + "position": 16, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "error_message", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "payload_json", + "position": 18, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "config_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_tested_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_test_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "consecutive_failures", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_failure_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_reason", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "rule_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "incident_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "rule_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "group_key", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "signal_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "severity", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "peak_severity", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "comparator", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "threshold", + "position": 11, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "threshold_upper", + "position": 12, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "first_triggered_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_triggered_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "resolved_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_observed_value", + "position": 16, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_sample_count", + "position": 17, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_evaluated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "dedupe_key", + "position": 19, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_delivered_event_type", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_notified_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "hold_reason", + "position": 22, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "held_since", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "error_issue_id", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "created_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "updated_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "destination_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "thread_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_team_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_issue_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_identifier", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_url", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "incident_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "rule_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "last_scheduled_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "consecutive_limit_failures", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "rule_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "group_key", + "position": 2, + "type": "text", + "notNull": true, + "default": "'__total__'", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "consecutive_breaches", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "consecutive_healthy", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "history_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_status", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_value", + "position": 7, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_sample_count", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_evaluated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_error_kind", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "notes", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "notification_template_json", + "position": 4, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "severity", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "service_names_json", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "exclude_service_names_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "environments_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "tags_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "signal_type", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "comparator", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold", + "position": 13, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold_upper", + "position": 14, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "thresholds_json", + "position": 15, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "window_minutes", + "position": 16, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "evaluation_interval_minutes", + "position": 17, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "slowed_interval_minutes", + "position": 18, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "slowed_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "minimum_sample_count", + "position": 20, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_breaches_required", + "position": 21, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "breaches_out_of", + "position": 22, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_healthy_required", + "position": 23, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "renotify_interval_minutes", + "position": 24, + "type": "integer", + "notNull": true, + "default": "30", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "apdex_threshold_ms", + "position": 25, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_builder_draft_json", + "position": 26, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "raw_query_sql", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "group_by", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "destination_ids_json", + "position": 29, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_spec_json", + "position": 30, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "reducer", + "position": 31, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "sample_count_strategy", + "position": 32, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "no_data_behavior", + "position": 33, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "last_scheduled_at", + "position": 34, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_at", + "position": 35, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_at", + "position": 36, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_by", + "position": 37, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_by", + "position": 38, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "sensitivity", + "position": 2, + "type": "text", + "notNull": true, + "default": "'normal'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "muted_signals_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "last_tick_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "detector_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "signal_type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "service_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "deployment_env", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "fingerprint_hash", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_breaches", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_healthy", + "position": 7, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_status", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_value", + "position": 9, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "baseline_median", + "position": 10, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_sample_count", + "position": 11, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_evaluated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "open_incident_id", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_resolved_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_incident_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "detector_key", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "signal_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "deployment_env", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprint_hash", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "error_issue_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "opened_value", + "position": 10, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_median", + "position": 11, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_sigma", + "position": 12, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "threshold_value", + "position": 13, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_observed_value", + "position": 14, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_sample_count", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "first_triggered_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_triggered_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolved_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolve_reason", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "triage_status", + "position": 20, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "dedupe_key", + "position": 21, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprints_json", + "position": 22, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "reopen_count", + "position": 23, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_reopened_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "created_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "updated_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_prefix", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "last_used_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "metadata_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "scopes", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "kind", + "position": 12, + "type": "text", + "notNull": true, + "default": "'standard'", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by_email", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "account_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "modules", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "granted_modules", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "template_version", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "verified_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "last_checked_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "last_check_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "check_lease_until", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "metric_stream", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "metric_stream_changed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_ciphertext", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_iv", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_tag", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_hash", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_last_received_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_last_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "created_by", + "position": 19, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "updated_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "plan_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "subscription_started_at", + "position": 3, + "type": "bigint", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "canceled_at", + "position": 4, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "rule_reason", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "posted_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "external_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "display_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "external_workspace_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "settings", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_ciphertext", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_iv", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_tag", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_code_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "user_code_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_org_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_roles", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_email", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "api_key_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_ciphertext", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_iv", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_tag", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "denied_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "dataset", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_id", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "watermark_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "backfill_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_json", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_fetched_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "quantiles_available", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "discovered_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "live_scripts_json", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "grant_call_cap", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_success_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "lease_until", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "config_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_host", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_port", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_scheme", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_database", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_user", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "deleted_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "zone_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "service_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "dataset", + "position": 6, + "type": "text", + "notNull": true, + "default": "'http_requests'", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_ciphertext", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_iv", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_tag", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_hash", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "enabled", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_received_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_rotated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "widget_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "mode", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_hash", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_suffix", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "revoked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "version_number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "snapshot_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_summary", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "source_version_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_by", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "payload_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "version", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "installation_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "public_jwk", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "jkt", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "roles", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "scopes", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "attested", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "attestation", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "last_used_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "expires_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "hard_expires_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "revoked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_proof_replays", + "name": "key", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_proof_replays", + "name": "expires_at", + "position": 1, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "email", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "opted_out_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "day_of_week", + "position": 6, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "timezone", + "position": 7, + "type": "text", + "notNull": true, + "default": "'UTC'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "send_hour", + "position": 8, + "type": "integer", + "notNull": true, + "default": "9", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "namespaces_json", + "position": 9, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "environments_json", + "position": 10, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_sent_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_attempted_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_enabled", + "position": 13, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_opted_out_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_sent_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_attempted_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "fingerprint_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_message", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "error_label", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "top_frame", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "first_seen_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "last_seen_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "reason", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "first_triggered_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "last_triggered_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "resolved_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "actor_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "type", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "from_state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "to_state", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "payload_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "repo_full_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "number", + "position": 6, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "url", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "title", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "author_login", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "state", + "position": 10, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merged_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merge_commit_sha", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "link_source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "linked_by_actor_id", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "issue_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_observed_occurrence_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_evaluated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "open_incident_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "pull_request_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "status", + "position": 4, + "type": "text", + "notNull": true, + "default": "'waiting'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "merged_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verify_after", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_rate_per_hour", + "position": 9, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "investigation_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict_note", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "post_merge_occurrence_count", + "position": 13, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "attempt", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "kind", + "position": 2, + "type": "text", + "notNull": true, + "default": "'error'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "source_ref_json", + "position": 3, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_version", + "position": 5, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "service_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_type", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_message", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "error_label", + "position": 9, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "top_frame", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "workflow_state", + "position": 11, + "type": "text", + "notNull": true, + "default": "'triage'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "priority", + "position": 12, + "type": "integer", + "notNull": true, + "default": "3", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity_source", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "assigned_actor_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_holder_actor_id", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "claimed_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "notes", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "first_seen_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_seen_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "occurrence_count", + "position": 22, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_by_actor_id", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_resolved_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_regressed_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "regression_count", + "position": 27, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "seen_versions_json", + "position": 28, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_versions_json", + "position": 29, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "snooze_until", + "position": 30, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "archived_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "created_at", + "position": 32, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "updated_at", + "position": 33, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "destination_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "delivery_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "payload_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "status", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempt_count", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "scheduled_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claim_expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempted_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "error_message", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "destination_ids_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_first_seen", + "position": 3, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_regression", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_resolve", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_in_review", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_done", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_claim", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "min_occurrence_count", + "position": 9, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "severity", + "position": 10, + "type": "text", + "notNull": true, + "default": "'warning'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "processed_through", + "position": 1, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "bootstrap_completed", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_token", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_expires_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "scope_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "scope_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "project_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "logs_enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_ciphertext", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_iv", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_tag", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_hash", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_received_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "applied_logs_enabled", + "position": 14, + "type": "boolean", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "applied_metrics_enabled", + "position": 15, + "type": "boolean", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "setup_reported_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_watermark_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_metrics_received_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_metrics_error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_lease_until", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "resources_synced_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_resources_error", + "position": 22, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "created_by", + "position": 23, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "created_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "updated_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "connector_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "asset_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "project_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "location", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "display_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "state", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "labels", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "details", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "resource_created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "resource_updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "last_seen_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": "'investigating'", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "seeded_by", + "position": 3, + "type": "text", + "notNull": true, + "default": "'user'", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "subject_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "incident_kind", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "incident_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "issue_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "report_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "progress_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "severity", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "confidence", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "model", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "input_tokens", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "output_tokens", + "position": 15, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "started_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "autonomous_turns", + "position": 18, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "created_by", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "diagnosed_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "updated_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "rules_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "updated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "severity", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "source", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "reason", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "run_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "investigation_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "payload_json", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "delivery_results_json", + "position": 9, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "attempts", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "dedupe_key", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "processed_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "device_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "incident_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "activity_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "push_token", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "ended_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "ended_reason", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "request_id_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "client_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "client_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "redirect_uri", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "state", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "resource", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "scopes", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "code_challenge", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "authorization_code_hash", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_org_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_user_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_roles", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_user_email", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "denied_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "used_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_name", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "redirect_uris", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_uri", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "token_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "family_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "client_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "resource", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "scopes", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "org_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "user_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "roles", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "user_email", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "access_key_id", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "replaced_by_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "revoked_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "family_expires_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "platform", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "token", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "environment", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "bundle_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "app_version", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "live_activity_start_token", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "device_name", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "preferences", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "disabled_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "disabled_reason", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "last_pushed_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "last_seen_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "state", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "initiated_by_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "redirect_uri", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "return_to", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "code_verifier", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "expires_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_user_email", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_account_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "granted_accounts_json", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "connected_by_user_id", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "scope", + "position": 9, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_ciphertext", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_iv", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_tag", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_ciphertext", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_iv", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_tag", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "expires_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "revoked_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "created_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "updated_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "workflow_instance_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "phase", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "current_migration", + "position": 4, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "steps_total", + "position": 5, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "steps_done", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "applied_versions", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "skipped", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "error_message", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "started_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "finished_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_url", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_user", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_ciphertext", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_iv", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_tag", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_database", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "sync_status", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "last_sync_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "last_sync_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "schema_version", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "created_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "updated_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "source_context", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "source_key", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "target_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "operation", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_key_hash", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_ciphertext", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_iv", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_tag", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_hash", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_rotated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_rotated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_settings", + "name": "hash_salt", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_settings", + "name": "created_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "rule_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "bucket", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "signal", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "matched", + "position": 4, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "dropped", + "position": 5, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "dropped_bytes", + "position": 6, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "redacted_values", + "position": 7, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "enabled", + "position": 3, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "position", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "spec_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "trace_sample_ratio", + "position": 1, + "type": "double precision", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "always_keep_error_spans", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "always_keep_slow_spans_ms", + "position": 3, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "user_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "email", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "role", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "demo_data_requested", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "onboarding_completed_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "checklist_dismissed_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "first_data_received_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "welcome_email_sent_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "connect_nudge_email_sent_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "stalled_email_sent_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "activation_email_sent_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "reward_claimed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "reward_reserved_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_ciphertext", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_iv", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_tag", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_hint", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "number", + "position": 2, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "recommendation_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "source_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "canonical_key", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "status", + "position": 7, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "usage_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "opened_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "resolved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "slack_channel_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "slack_channel_name", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "reservation_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "reserved_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "created_by_user_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "ps_organization", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "connected_by_user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "scrape_target_id", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_ciphertext", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_iv", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_tag", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "detected_permissions_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "last_inventory_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "last_inventory_error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "database_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": "'mysql'", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "region", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "plan", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "branches_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_secret_ciphertext", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_secret_iv", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_secret_tag", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "deleted_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "database_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "database_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "branch_name", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "category", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "event_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "state", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "external_id", + "position": 8, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "title", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "source", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "actor_login", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "url", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "payload_json", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "occurred_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "event_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "processed_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "dataset", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "database_id", + "position": 3, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "watermark_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_success_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_error", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_error_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "lease_until", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "reply_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "seq", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "path", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "old_text", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "new_text", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "finding_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "model", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "embedding", + "position": 4, + "type": "real[]", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "review_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "handle", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "path", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "line", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "category", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "title", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "status", + "position": 11, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "comment_id", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "resolved_sha", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "reactions_up", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "reactions_down", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "updated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "key", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "title", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "source", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "path", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "done_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "done_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "reminded_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "first_review_id", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "last_review_id", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "comment_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "surface", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "thread_root_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "author_login", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "command", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "head_sha", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "session_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "reply_url", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "commit_sha", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "error", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "model", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "defaults", + "position": 2, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "updated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "head_sha", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "base_sha", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "url", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "title", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "author_login", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "skip_reason", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "session_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "comment_attempt", + "position": 12, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "report_json", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "score", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "check_run_url", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "comment_url", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "review_url", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "publish_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "model", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "input_tokens", + "position": 21, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "output_tokens", + "position": 22, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "started_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "finished_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "merged_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "telemetry_json", + "position": 26, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "merge_commit_sha", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_status", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_after", + "position": 29, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_json", + "position": 30, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "created_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "updated_at", + "position": 32, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_ciphertext", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_iv", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_tag", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "workspace_names", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "connected_by_user_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "auth_failed_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "discovered_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "lease_until", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_success_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_error", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_error_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "project_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "project_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "environment_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "environment_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "services_json", + "position": 7, + "type": "text", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "enabled", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "watermark_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_success_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_error_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "id", + "position": 0, + "type": "integer", + "notNull": true, + "default": null, + "identity": "by default" + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "target_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "sub_target_key", + "position": 3, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "checked_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "error", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "duration_ms", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "samples_scraped", + "position": 7, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "samples_post_relabel", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "url", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "target_type", + "position": 6, + "type": "text", + "notNull": true, + "default": "'prometheus'", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "discovery_config_json", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "scrape_interval_seconds", + "position": 8, + "type": "integer", + "notNull": true, + "default": "15", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "labels_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_type", + "position": 10, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "managed_by", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_ciphertext", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_iv", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_tag", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "enabled", + "position": 15, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "last_scrape_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "last_scrape_error", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "created_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "updated_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "run_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "role", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "agent_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "outcome", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "detail", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "input_tokens", + "position": 9, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "output_tokens", + "position": 10, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "target_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "fingerprint", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "verdict", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "overall_severity", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "estimated_severity", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "record", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "first_seen_run_id", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "last_seen_run_id", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "first_seen_ref", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "last_seen_ref", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "fixed_run_id", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status_reason", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status_changed_by", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status_changed_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "run_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "agent_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "cost_usd", + "position": 3, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "sandbox_fee_usd", + "position": 4, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "monthly_spend_limit_usd", + "position": 1, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "discovery_model", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "validation_model", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "run_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "fingerprint", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "verdict", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "overall_severity", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "estimated_severity", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "record", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "target_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "source_ref", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "profile", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "scope_paths", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "budget", + "position": 7, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "metadata", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'in_progress'", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "incomplete_reason", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "architecture_md", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "reports", + "position": 12, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "execution_profile", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "invocations_spent", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "input_tokens", + "position": 15, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "output_tokens", + "position": 16, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "cost_usd", + "position": 17, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "sandbox_usd", + "position": 18, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "discovery_model", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "validation_model", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "workflow_instance_id", + "position": 21, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "started_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "finished_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "error", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "repository", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "vcs_repository_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "branch", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "profile", + "position": 6, + "type": "text", + "notNull": true, + "default": "'deep'", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "scope_paths", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "slice_interval_hours", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "revalidate_on_push", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "budget_per_run", + "position": 10, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "monthly_invocation_budget", + "position": 11, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "discovery_model", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "validation_model", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "created_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "run_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "coverage_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "wave", + "position": 4, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "agent_id", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "unit", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "platform", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "key_hash", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "key_prefix", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "allowed_signals", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "rate_limit_rps", + "position": 9, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "resource_attributes", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": "'[]'::jsonb", + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "expires_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "paused_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "paused_until", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_used_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_rejected_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_rejection", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_ok_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "disconnected_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "poll_resources", + "position": 21, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "rolled_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "created_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "updated_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "created_by", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "updated_by", + "position": 26, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "page_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "slug", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "delete_snapshot", + "position": 4, + "type": "boolean", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "page_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "description", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "group_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "position", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "monitor_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "manual_status", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "show_uptime", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_components", + "name": "incident_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_components", + "name": "component_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_components", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "body", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "source", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "overlay_entry_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "page_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "title", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "impact", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "status", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "published", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "alert_incident_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "scheduled_start", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "scheduled_end", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "started_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "resolved_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "created_by", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "slug", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "description", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "website_url", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "logo_url", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "privacy_url", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "terms_url", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "theme", + "position": 9, + "type": "jsonb", + "notNull": true, + "default": "'{\"preset\":\"default\",\"appearance\":\"auto\",\"accent\":null,\"background\":null,\"text\":null,\"card\":null,\"border\":null,\"radius\":\"md\",\"font\":\"sans\"}'", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "show_powered_by", + "position": 10, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "auto_incidents", + "position": 11, + "type": "text", + "notNull": true, + "default": "'off'", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "break_glass_token_sha256", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "break_glass_token_suffix", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "draft", + "position": 14, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "revision", + "position": 15, + "type": "bigint", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "published_revision", + "position": 16, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "published_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "last_publish_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "published_hash", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "publish_attempted_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "created_by", + "position": 21, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "created_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "updated_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "supabase_organization_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "supabase_organization_slug", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "supabase_organization_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "connected_by_user_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "discovery_lease_until", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "last_discovery_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "last_discovery_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "project_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "advisors_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "top_queries_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "advisors_checked_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "queries_checked_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "advisors_error", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "queries_error", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "lease_until", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "project_ref", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "region", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "status", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "metrics_enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "scrape_target_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_enabled", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_source_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_error", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drains_json", + "position": 13, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drains_checked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_enabled", + "position": 15, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_watermark_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_boundary_ids", + "position": 17, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_lease_until", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_last_polled_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_error", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "last_seen_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "removed_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "created_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "updated_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "monitor_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "day", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "checks", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "failures", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "down_checks", + "position": 5, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "last_check_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "monitor_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "consecutive_failures", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "consecutive_successes", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "status_changed_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "last_check_json", + "position": 6, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "recent_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "last_ping_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "config_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "interval_seconds", + "position": 5, + "type": "integer", + "notNull": true, + "default": "60", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "failures_to_down", + "position": 6, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "service_name", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "alert_rule_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "heartbeat_token", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "enabled", + "position": 10, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "repository_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "sha", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "message", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_email", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_login", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_avatar_url", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "authored_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "committed_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "html_url", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "external_installation_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_login", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "external_account_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_avatar_url", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "repository_selection", + "position": 9, + "type": "text", + "notNull": true, + "default": "'all'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "suspended_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "ci_traces_enabled", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "installed_by_user_id", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "installation_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "owner", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "full_name", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "default_branch", + "position": 8, + "type": "text", + "notNull": true, + "default": "'main'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "tracked_branch", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "html_url", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "is_private", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "is_archived", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "status", + "position": 13, + "type": "text", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "sync_status", + "position": 14, + "type": "text", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "last_synced_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "last_sync_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "pr_review_enabled", + "position": 17, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "pr_review_config", + "position": 18, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "repository_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "is_default", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "head_sha", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "updated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_agent_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"agent_name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"" + ], + "where": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "agent_feedback", + "name": "agent_feedback_kind_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"kind\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "agent_feedback", + "name": "agent_feedback_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"claim_expires_at\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_delivery_attempt_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"attempt_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_incident_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"incident_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_open_group_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"rule_id\"", + "\"group_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_rule_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"rule_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_linear_issues", + "name": "alert_linear_issues_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_linear_issues", + "name": "alert_linear_issues_thread_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"destination_id\"", + "\"thread_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rule_claims", + "name": "alert_rule_claims_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rule_states", + "name": "alert_rule_states_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_evaluated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"last_evaluated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_open_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"open_incident_id\"" + ], + "where": "\"open_incident_id\" is not null" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_open_detector_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_detector_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_status_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"", + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_org_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_org_account_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_stream_secret_hash_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"stream_secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cancellation_reviews", + "name": "cancellation_reviews_subscription_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"plan_id\"", + "\"subscription_started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"external_user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"connector\"", + "\"external_workspace_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_user_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"user_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_account_dataset_zone_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"", + "\"dataset\"", + "\"zone_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_config_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"config_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_live_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "coalesce(widget_id, '')" + ], + "where": "revoked_at is null" + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_token_hash_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_version_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_name_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_updated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"updated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_jkt_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"jkt\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_live_installation_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"installation_id\"" + ], + "where": "\"revoked_at\" IS NULL" + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_proof_replays", + "name": "device_proof_replays_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_fingerprint_candidates", + "name": "error_fingerprint_candidates_last_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_actor_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"actor_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_pr_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"verify_after\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_open_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": "\"status\" in ('waiting', 'running')" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_lease_expiry_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"lease_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_archived_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"archived_at\"" + ], + "where": "\"archived_at\" is not null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_assignee_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"assigned_actor_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_version_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_version\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_live_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"", + "\"id\"" + ], + "where": "\"archived_at\" is null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_severity_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"severity\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_workflow_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"workflow_state\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"", + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_key_destination_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"destination_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_tick_states", + "name": "error_tick_states_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_org_scope_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"scope_type\"", + "\"scope_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_incident_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_kind\"", + "\"incident_id\"" + ], + "where": "\"incident_id\" is not null" + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"dedupe_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "live_activities", + "name": "live_activities_device_incident_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"device_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "live_activities", + "name": "live_activities_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_authorizations", + "name": "mcp_oauth_authorizations_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"authorization_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_authorizations", + "name": "mcp_oauth_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_family_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"family_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_org_platform_token_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"platform\"", + "\"token\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_auth_states", + "name": "oauth_auth_states_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_org_provider_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_attribute_mappings", + "name": "org_ingest_attribute_mappings_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_private_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"private_key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_public_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"public_key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_public_key_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"public_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_rule_stats", + "name": "org_ingest_rule_stats_org_bucket_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"bucket\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_rules", + "name": "org_ingest_rules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_recommendation_issues", + "name": "org_recommendation_issues_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_recommendation_issues", + "name": "org_recommendation_issues_org_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"recommendation_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_connections", + "name": "planetscale_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_connections", + "name": "planetscale_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_databases", + "name": "planetscale_databases_org_db_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_databases", + "name": "planetscale_databases_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_name\"", + "\"event_type\"", + "\"external_id\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_org_db_time_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_name\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_org_time_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_issue_receipts", + "name": "planetscale_issue_receipts_processed_at_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"processed_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_poll_state", + "name": "planetscale_poll_state_org_dataset_db_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dataset\"", + "\"database_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_poll_state", + "name": "planetscale_poll_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_edits", + "name": "pr_review_edits_reply_seq_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"reply_id\"", + "\"seq\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_finding_embeddings", + "name": "pr_review_finding_embeddings_org_model_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"model\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_findings", + "name": "pr_review_findings_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_findings", + "name": "pr_review_findings_pr_handle_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"handle\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_merge_steps", + "name": "pr_review_merge_steps_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_merge_steps", + "name": "pr_review_merge_steps_pr_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_replies", + "name": "pr_review_replies_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_replies", + "name": "pr_review_replies_repo_comment_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"comment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_post_merge_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"post_merge_after\"" + ], + "where": "\"post_merge_status\" = 'waiting'" + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_repo_number_head_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"head_sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_connections", + "name": "railway_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_connections", + "name": "railway_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_environments", + "name": "railway_environments_connection_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"connection_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_environments", + "name": "railway_environments_org_env_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"environment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_target_checks", + "name": "scrape_target_checks_target_checked_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"checked_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_events", + "name": "security_audit_events_run_at_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"run_id\"", + "\"at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_findings", + "name": "security_audit_findings_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_findings", + "name": "security_audit_findings_target_fingerprint_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"fingerprint\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_org_reservations", + "name": "security_audit_org_reservations_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_runs", + "name": "security_audit_runs_one_running_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"target_id\"" + ], + "where": "\"finished_at\" IS NULL" + }, + { + "kind": "index", + "table": "security_audit_runs", + "name": "security_audit_runs_org_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_runs", + "name": "security_audit_runs_target_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_targets", + "name": "security_audit_targets_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_targets", + "name": "security_audit_targets_org_repo_branch_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"repository\"", + "\"branch\"" + ], + "where": null + }, + { + "kind": "index", + "table": "sources", + "name": "sources_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "sources", + "name": "sources_org_default_kind_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": "\"kind\" = 'default'" + }, + { + "kind": "index", + "table": "sources", + "name": "sources_org_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_cleanups", + "name": "status_page_cleanups_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_components", + "name": "status_page_components_monitor_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"monitor_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_components", + "name": "status_page_components_page_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"page_id\"", + "\"position\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incident_components", + "name": "status_page_incident_components_component_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"component_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incident_updates", + "name": "status_page_incident_updates_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"incident_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incident_updates", + "name": "status_page_incident_updates_overlay_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"overlay_entry_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incidents", + "name": "status_page_incidents_alert_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"alert_incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incidents", + "name": "status_page_incidents_page_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"page_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_pages", + "name": "status_pages_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_pages", + "name": "status_pages_slug_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"slug\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_pages", + "name": "status_pages_unpublished_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"revision\"", + "\"published_revision\"" + ], + "where": "revision > published_revision" + }, + { + "kind": "index", + "table": "supabase_connections", + "name": "supabase_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_connections", + "name": "supabase_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_project_insights", + "name": "supabase_project_insights_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_projects", + "name": "supabase_projects_connection_ref_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"connection_id\"", + "\"project_ref\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_projects", + "name": "supabase_projects_log_drain_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"log_drain_source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_projects", + "name": "supabase_projects_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitor_daily", + "name": "uptime_monitor_daily_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitor_states", + "name": "uptime_monitor_states_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitors", + "name": "uptime_monitors_enabled_kind_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"enabled\"", + "\"kind\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitors", + "name": "uptime_monitors_heartbeat_token_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"heartbeat_token\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitors", + "name": "uptime_monitors_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_commits", + "name": "vcs_commits_org_sha_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_commits", + "name": "vcs_commits_repo_sha_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_provider_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"provider\"", + "\"external_installation_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_installation_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"installation_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_org_repo_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"external_repo_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repository_branches", + "name": "vcs_repository_branches_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repository_branches", + "name": "vcs_repository_branches_repo_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "foreign_key", + "table": "alert_linear_issues", + "name": "alert_linear_issues_destination_id_alert_destinations_id_fk", + "columns": [ + "destination_id" + ], + "foreignTable": "alert_destinations", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "aws_connections", + "name": "aws_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "SET NULL", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "dashboard_shares", + "name": "dashboard_shares_dashboard_fk", + "columns": [ + "org_id", + "dashboard_id" + ], + "foreignTable": "dashboards", + "foreignColumns": [ + "org_id", + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "gcp_connectors", + "name": "gcp_connectors_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "gcp_resources", + "name": "gcp_resources_connector_id_gcp_connectors_id_fk", + "columns": [ + "connector_id" + ], + "foreignTable": "gcp_connectors", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "oauth_connections", + "name": "oauth_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "planetscale_connections", + "name": "planetscale_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "railway_connections", + "name": "railway_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "scrape_target_checks", + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "columns": [ + "target_id" + ], + "foreignTable": "scrape_targets", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "scrape_targets", + "name": "scrape_targets_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_components", + "name": "status_page_components_monitor_id_uptime_monitors_id_fk", + "columns": [ + "monitor_id" + ], + "foreignTable": "uptime_monitors", + "foreignColumns": [ + "id" + ], + "onDelete": "SET NULL", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_components", + "name": "status_page_components_page_id_status_pages_id_fk", + "columns": [ + "page_id" + ], + "foreignTable": "status_pages", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incident_components", + "name": "status_page_incident_components_Bnqlt6wwKJty_fk", + "columns": [ + "component_id" + ], + "foreignTable": "status_page_components", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incident_components", + "name": "status_page_incident_components_tBn6mEz4FenY_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "status_page_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incident_updates", + "name": "status_page_incident_updates_3LDDUAhznHPM_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "status_page_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incidents", + "name": "status_page_incidents_page_id_status_pages_id_fk", + "columns": [ + "page_id" + ], + "foreignTable": "status_pages", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_connections", + "name": "supabase_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_project_insights", + "name": "supabase_project_insights_project_id_supabase_projects_id_fk", + "columns": [ + "project_id" + ], + "foreignTable": "supabase_projects", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_projects", + "name": "supabase_projects_connection_id_supabase_connections_id_fk", + "columns": [ + "connection_id" + ], + "foreignTable": "supabase_connections", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_projects", + "name": "supabase_projects_log_drain_source_id_sources_id_fk", + "columns": [ + "log_drain_source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "SET NULL", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "uptime_monitor_daily", + "name": "uptime_monitor_daily_monitor_id_uptime_monitors_id_fk", + "columns": [ + "monitor_id" + ], + "foreignTable": "uptime_monitors", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "uptime_monitor_states", + "name": "uptime_monitor_states_monitor_id_uptime_monitors_id_fk", + "columns": [ + "monitor_id" + ], + "foreignTable": "uptime_monitors", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "vcs_installations", + "name": "vcs_installations_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + } + ] +} diff --git a/packages/db/src/tables/status-pages.ts b/packages/db/src/tables/status-pages.ts index 21b310370d..5c1bac6599 100644 --- a/packages/db/src/tables/status-pages.ts +++ b/packages/db/src/tables/status-pages.ts @@ -46,6 +46,8 @@ export const StatusPages = PG.table("status_pages", { /** SHA-256 (base64url) of the break-glass token, published so the edge can check it. */ breakGlassTokenSha256: PG.column(PG.nullable(PG.text), { name: "break_glass_token_sha256" }), breakGlassTokenSuffix: PG.column(PG.nullable(PG.text), { name: "break_glass_token_suffix" }), + /** A draft is never published: the publisher skips it until it is made public. */ + draft: PG.column(PG.bool, { default: false }), revision: PG.column(PG.int8, { default: 1 }), publishedRevision: PG.column(PG.int8, { name: "published_revision", default: 0 }), publishedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "published_at" }), diff --git a/packages/domain/src/http/status-pages.ts b/packages/domain/src/http/status-pages.ts index cb618a967f..bb629e9ca8 100644 --- a/packages/domain/src/http/status-pages.ts +++ b/packages/domain/src/http/status-pages.ts @@ -156,6 +156,8 @@ export const StatusPageRecord = Schema.Struct({ showPoweredBy: Schema.Boolean, autoIncidents: StatusPageAutoIncidents, breakGlassTokenSuffix: Schema.NullOr(Schema.String), + /** Not public until published; the publisher skips a draft. */ + draft: Schema.Boolean, revision: Schema.Number, publishedRevision: Schema.Number, publishedAt: Schema.NullOr(Schema.Number), diff --git a/packages/domain/src/http/v2/auth.ts b/packages/domain/src/http/v2/auth.ts index 2320ae5f84..fca0ea9f34 100644 --- a/packages/domain/src/http/v2/auth.ts +++ b/packages/domain/src/http/v2/auth.ts @@ -95,6 +95,7 @@ const READ_ONLY_POST_PATHS = new Set([ "/v2/session_replays/search", "/v2/session_replays/for_trace", "/v2/alerts/rules/preview", + "/v2/status_pages/preview", "/v2/services/health", "/v2/traces/search", "/v2/traces/timeseries", diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index c032d23467..03e32f0572 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -269,6 +269,7 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/status_page_incidents", "POST /v2/status_page_incidents/{id}/updates", "POST /v2/status_pages", + "POST /v2/status_pages/preview", "POST /v2/status_pages/{id}/components", "POST /v2/status_pages/{id}/components/reorder", "POST /v2/status_pages/{id}/publish", diff --git a/packages/domain/src/http/v2/status-pages.ts b/packages/domain/src/http/v2/status-pages.ts index 24fd2377a0..17608ffecf 100644 --- a/packages/domain/src/http/v2/status-pages.ts +++ b/packages/domain/src/http/v2/status-pages.ts @@ -1,5 +1,5 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" -import { Schema } from "effect" +import { Schema, Struct } from "effect" import { StatusPageAutoIncidents, StatusPageComponentId, @@ -17,6 +17,7 @@ import { StatusPageThemeAppearance, StatusPageThemeFont, StatusPageThemePreset, + StatusPageSnapshot, StatusPageThemeRadius, } from "../../status-page" import { @@ -155,6 +156,7 @@ const statusPageExample = { show_powered_by: true, auto_incidents: "draft", break_glass_token_suffix: null, + draft: false, revision: 4, published_revision: 4, published_at: "2026-07-15T09:12:00.000Z", @@ -186,6 +188,10 @@ export const V2StatusPage = Schema.Struct({ break_glass_token_suffix: Schema.NullOr(Schema.String).annotate({ description: "Last 6 characters of the break-glass token, or `null` when none is issued.", }), + draft: Schema.Boolean.annotate({ + description: + "True while the page is a draft: it is not public until `POST /v2/status_pages/{id}/publish`.", + }), revision: Schema.Number.annotate({ description: "Bumped by every change a visitor could see." }), published_revision: Schema.Number.annotate({ description: "The revision now served. Behind `revision` until the next publish.", @@ -207,25 +213,6 @@ export const V2StatusPage = Schema.Struct({ }) export type V2StatusPage = Schema.Schema.Type -export const V2StatusPageCreateParams = Schema.Struct({ - name: NonEmptyString.annotate({ description: "Page title. Required.", examples: ["Acme Status"] }), - slug: slugField, - description: Schema.optionalKey(Schema.NullOr(Schema.String)), - website_url: Schema.optionalKey(Schema.NullOr(Schema.String)), - logo_url: Schema.optionalKey(Schema.NullOr(Schema.String)), - privacy_url: Schema.optionalKey(Schema.NullOr(Schema.String)), - terms_url: Schema.optionalKey(Schema.NullOr(Schema.String)), - theme: Schema.optionalKey(V2StatusPageThemeParams), - show_powered_by: Schema.optionalKey(Schema.Boolean), - auto_incidents: Schema.optionalKey(autoIncidentsField), -}).annotate({ - identifier: "StatusPageCreateParams", - title: "Status page create parameters", - description: "Request body for creating a status page.", - examples: [wireExample({ name: "Acme Status", slug: "acme-status" })], -}) -export type V2StatusPageCreateParams = Schema.Schema.Type - export const V2StatusPageUpdateParams = Schema.Struct({ name: Schema.optionalKey(NonEmptyString), slug: Schema.optionalKey(slugField), @@ -457,12 +444,7 @@ export const StatusPageConfigTheme = Schema.Struct({ examples: [wireExample(themeExample)], }) -export const StatusPageConfigComponent = Schema.Struct({ - id: Schema.optionalKey( - StatusPageComponentPublicId.annotate({ - description: "An existing component of this page (`stc_…`) to update. Omit to add a new one.", - }), - ), +const draftComponentFields = { name: NonEmptyString.annotate({ examples: ["API"] }), description: Schema.optionalKey(Schema.NullOr(Schema.String)), group: Schema.optionalKey( @@ -477,6 +459,15 @@ export const StatusPageConfigComponent = Schema.Struct({ show_uptime: Schema.optionalKey( Schema.Boolean.annotate({ description: "Whether the page shows its uptime bars. Default `true`." }), ), +} + +export const StatusPageConfigComponent = Schema.Struct({ + id: Schema.optionalKey( + StatusPageComponentPublicId.annotate({ + description: "An existing component of this page (`stc_…`) to update. Omit to add a new one.", + }), + ), + ...draftComponentFields, }).annotate({ identifier: "StatusPageConfigComponent", title: "Status page config component", @@ -484,9 +475,49 @@ export const StatusPageConfigComponent = Schema.Struct({ }) export type StatusPageConfigComponent = Schema.Schema.Type +/** A component not saved yet: a config component without `id`. */ +export const StatusPageDraftComponent = Schema.Struct(draftComponentFields).annotate({ + identifier: "StatusPageDraftComponent", + title: "Status page draft component", + description: "A new component; its place in the list is its position on the page.", +}) +export type StatusPageDraftComponent = Schema.Schema.Type + /** Most components one page may have; the config and a single create both refuse more. */ export const MAX_STATUS_PAGE_COMPONENTS = 200 +export const V2StatusPageCreateParams = Schema.Struct({ + name: NonEmptyString.annotate({ description: "Page title. Required.", examples: ["Acme Status"] }), + slug: slugField, + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + website_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + logo_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + privacy_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + terms_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + theme: Schema.optionalKey(V2StatusPageThemeParams), + show_powered_by: Schema.optionalKey(Schema.Boolean), + auto_incidents: Schema.optionalKey(autoIncidentsField), + components: Schema.optionalKey( + Schema.Array(StatusPageDraftComponent) + .check(Schema.isMaxLength(MAX_STATUS_PAGE_COMPONENTS)) + .annotate({ + description: `The page's components, in display order (at most ${MAX_STATUS_PAGE_COMPONENTS}), created with the page in one write.`, + }), + ), + publish: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "`true` publishes the page right after it is created; `false` creates it as a draft, not public until `POST /v2/status_pages/{id}/publish`. Omitted, the page is public and published within a minute.", + }), + ), +}).annotate({ + identifier: "StatusPageCreateParams", + title: "Status page create parameters", + description: "Request body for creating a status page.", + examples: [wireExample({ name: "Acme Status", slug: "acme-status" })], +}) +export type V2StatusPageCreateParams = Schema.Schema.Type + export const StatusPageConfig = Schema.Struct({ version: Schema.optionalKey( Schema.String.annotate({ @@ -521,6 +552,37 @@ export const StatusPageConfig = Schema.Struct({ }) export type StatusPageConfig = Schema.Schema.Type +/** A config as typed so far: name and slug may still be blank or invalid, the preview falls back. */ +export const StatusPagePreviewParams = Schema.Struct({ + ...Struct.omit(StatusPageConfig.fields, ["version", "name", "slug", "components"]), + name: Schema.String.annotate({ + description: "Page title; blank previews as `Status page`.", + examples: ["Acme Status"], + }), + slug: Schema.String.annotate({ + description: "The page's address as typed; an invalid one previews as `preview`.", + examples: ["acme-status"], + }), + components: Schema.Array(StatusPageDraftComponent) + .check(Schema.isMaxLength(MAX_STATUS_PAGE_COMPONENTS)) + .annotate({ + description: `Every component, in display order (at most ${MAX_STATUS_PAGE_COMPONENTS}).`, + }), +}).annotate({ + identifier: "StatusPagePreviewParams", + title: "Status page preview parameters", + description: + "A status page config not saved yet. The preview fills in defaults and falls back to the default theme when the theme is invalid or hard to read.", + examples: [ + wireExample({ + name: "Acme Status", + slug: "acme-status", + components: [{ name: "API", monitor_id: "upm_YofPTrK9782DWwcnXhpcCw" }], + }), + ], +}) +export type StatusPagePreviewParams = Schema.Schema.Type + /** Optional text as the service stores it: trimmed, blank as `null`. */ const textOrNull = (value: string | null | undefined): string | null => { const trimmed = value?.trim() ?? "" @@ -920,7 +982,21 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") OpenApi.annotations({ identifier: "createStatusPage", summary: "Create a status page", - description: `Creates a status page; it is published within a minute. ${adminNote}`, + description: `Creates a status page with its components in one write. It is published within a minute, right away with \`publish: true\`, or created as a draft with \`publish: false\`. ${adminNote}`, + }), + ), + ) + .add( + HttpApiEndpoint.post("preview", "/preview", { + payload: StatusPagePreviewParams, + success: StatusPageSnapshot, + error: [V2ParameterInvalid.schema, validation, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "previewStatusPage", + summary: "Preview a status page", + description: + "Builds the snapshot a draft config would publish, from your organization's real uptime history. Writes nothing. Requires the `status_pages:read` scope.", }), ), ) @@ -974,7 +1050,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") OpenApi.annotations({ identifier: "publishStatusPage", summary: "Publish a status page", - description: `Publishes the page now instead of within the minute. ${adminNote}`, + description: `Publishes the page now instead of within the minute, making a draft public first. ${adminNote}`, }), ), ) diff --git a/packages/domain/src/mcp-outputs/status-pages.ts b/packages/domain/src/mcp-outputs/status-pages.ts index 5bd3839738..44cd5fc239 100644 --- a/packages/domain/src/mcp-outputs/status-pages.ts +++ b/packages/domain/src/mcp-outputs/status-pages.ts @@ -18,6 +18,8 @@ export const StatusPageRow = Schema.Struct({ name: Schema.String, slug: Schema.String, autoIncidents: StatusPageAutoIncidents, + /** A draft is not public until it is published. */ + draft: Schema.Boolean, revision: Schema.Number, publishedRevision: Schema.Number, /** ISO timestamp of the last publish; null before the first one. */ From f65a038557a2055f20cbee93c39ca9f80344f7f3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 23:35:50 +0100 Subject: [PATCH 02/12] feat(status): editor preview renders carry component and incident ids, and preview components take real history With the preview flag, components and incidents carry data-component-id and data-incident-id so the dashboard can select them; the public render is unchanged. previewSnapshot takes each monitor's days when the dashboard has them. --- packages/status-page/src/document.ts | 6 ++++ packages/status-page/src/page.ts | 30 ++++++++++------ packages/status-page/src/preview.ts | 9 +++-- packages/status-page/src/render.test.ts | 47 +++++++++++++++++++++++-- 4 files changed, 75 insertions(+), 17 deletions(-) diff --git a/packages/status-page/src/document.ts b/packages/status-page/src/document.ts index 8dfb42f796..5f4c254bef 100644 --- a/packages/status-page/src/document.ts +++ b/packages/status-page/src/document.ts @@ -8,11 +8,17 @@ export interface RenderOptions { readonly nonce?: string /** Prefix of the page's own links: `""` on its own host, `/s/` on a shared one. */ readonly basePath?: string + /** The dashboard's editor preview: components and incidents carry ids it can select by. Never public. */ + readonly preview?: boolean } export const nonceAttr = (options: RenderOptions): string => options.nonce === undefined ? "" : ` nonce="${escapeHtml(options.nonce)}"` +/** ` data--id=""` on a preview render, nothing on the public page. */ +export const previewIdAttr = (options: RenderOptions, kind: "component" | "incident", id: string): string => + options.preview === true ? ` data-${kind}-id="${escapeHtml(id)}"` : "" + export const pageHref = (options: RenderOptions, path: string): string => escapeHtml(`${options.basePath ?? ""}${path}`) diff --git a/packages/status-page/src/page.ts b/packages/status-page/src/page.ts index bf78054c17..62f5926952 100644 --- a/packages/status-page/src/page.ts +++ b/packages/status-page/src/page.ts @@ -12,7 +12,7 @@ import { uptimePercent, utcDay, } from "@maple/domain/status-page" -import { pageHref, type RenderOptions, renderDocument, renderHeader } from "./document" +import { pageHref, previewIdAttr, type RenderOptions, renderDocument, renderHeader } from "./document" import { escapeHtml, safeUrl } from "./escape" import { COMPONENT_STATUS_LABEL, @@ -67,9 +67,9 @@ const incidentMeta = (incident: StatusPageSnapshotIncident): string => { return `Started ${timeTag(incident.startedAt)}` } -const renderIncident = (incident: StatusPageSnapshotIncident): string => { +const renderIncident = (incident: StatusPageSnapshotIncident, options: RenderOptions): string => { const status = incident.resolvedAt === null ? incidentComponentStatus(incident) : "operational" - return `

${statusIcon(status)}${escapeHtml(incident.title)}

${INCIDENT_STATUS_LABEL[incident.status]} · ${incidentMeta(incident)}
${renderUpdates(incident)}
` + return `

${statusIcon(status)}${escapeHtml(incident.title)}

${INCIDENT_STATUS_LABEL[incident.status]} · ${incidentMeta(incident)}
${renderUpdates(incident)}
` } const renderSection = (id: string, title: string, inner: string): string => @@ -120,7 +120,7 @@ const renderBars = (days: ReadonlyArray): string => { return `
    ${bars}
` } -const renderComponent = (component: StatusPageSnapshotComponent): string => { +const renderComponent = (component: StatusPageSnapshotComponent, options: RenderOptions): string => { const description = component.description === null ? "" : `

${escapeHtml(component.description)}

` const showsBars = component.showUptime && component.days.length > 0 @@ -129,10 +129,10 @@ const renderComponent = (component: StatusPageSnapshotComponent): string => { const uptime = !showsBars ? "" : `${percent === null ? "No data" : `${formatPercent(percent)} uptime`}` - return `
${statusName(component.name, component.status)}${uptime}
${description}${showsBars ? renderBars(days) : ""}
` + return `
${statusName(component.name, component.status)}${uptime}
${description}${showsBars ? renderBars(days) : ""}
` } -const renderSystemStatus = (snapshot: StatusPageSnapshot): string => { +const renderSystemStatus = (snapshot: StatusPageSnapshot, options: RenderOptions): string => { if (snapshot.components.length === 0) return "" const shown = barComponents(snapshot.components)[0] const label = shown === undefined ? "" : rangeLabel(windowDays(shown.days, 0)) @@ -143,7 +143,7 @@ const renderSystemStatus = (snapshot: StatusPageSnapshot): string => { group.name === null ? "" : `
${statusName(group.name, group.status)}
` - return `
${groupHead}${group.components.map(renderComponent).join("")}
` + return `
${groupHead}${group.components.map((component) => renderComponent(component, options)).join("")}
` }) .join("") return `
${head}${groups}
` @@ -281,18 +281,26 @@ export const renderPage = ( ), active.length === 0 ? "" - : renderSection("active", "Active incidents", active.map(renderIncident).join("")), + : renderSection( + "active", + "Active incidents", + active.map((incident) => renderIncident(incident, options)).join(""), + ), upcoming.length === 0 ? "" - : renderSection("upcoming", "Upcoming maintenance", upcoming.map(renderIncident).join("")), - renderSystemStatus(snapshot), + : renderSection( + "upcoming", + "Upcoming maintenance", + upcoming.map((incident) => renderIncident(incident, options)).join(""), + ), + renderSystemStatus(snapshot, options), renderCalendar(today, days, entries), renderSection( "past", "Past incidents", past.length === 0 ? `

No incidents reported recently.

` - : past.map(renderIncident).join(""), + : past.map((incident) => renderIncident(incident, options)).join(""), ), renderFooter(snapshot, freshness, nowMs, options), ``, diff --git a/packages/status-page/src/preview.ts b/packages/status-page/src/preview.ts index 4e8ef35212..dabb72ec8e 100644 --- a/packages/status-page/src/preview.ts +++ b/packages/status-page/src/preview.ts @@ -11,6 +11,7 @@ import { lastDays, STATUS_PAGE_WINDOW_DAYS, STATUS_PAGE_SNAPSHOT_VERSION, + type StatusPageDayBar, type StatusPageSnapshot, type StatusPageSnapshotIncident, type StatusPageSnapshotPage, @@ -25,12 +26,14 @@ export interface PreviewComponent { readonly manualStatus: StatusPageComponentStatus | null readonly showUptime: boolean readonly monitored: boolean + /** The monitor's real history, oldest first; one window of empty bars when absent. */ + readonly days?: ReadonlyArray } /** * A snapshot of what the page would look like with the editor's draft, for the dashboard - * preview. Monitored components get one window of empty bars: the preview shows shape, not - * history. The calendar marks the incidents passed in. + * preview. Monitored components show the `days` given, or one window of empty bars without + * them. The calendar marks the incidents passed in. */ export const previewSnapshot = (input: { readonly pageId: StatusPageId @@ -75,7 +78,7 @@ export const previewSnapshot = (input: { }), showUptime: component.showUptime, monitored: component.monitored, - days: component.showUptime && component.monitored ? emptyDays : [], + days: component.showUptime && component.monitored ? (component.days ?? emptyDays) : [], } }), incidents: input.incidents, diff --git a/packages/status-page/src/render.test.ts b/packages/status-page/src/render.test.ts index eae0537a4a..f942467287 100644 --- a/packages/status-page/src/render.test.ts +++ b/packages/status-page/src/render.test.ts @@ -674,8 +674,33 @@ describe("page theme", () => { }) }) +describe("editor preview render", () => { + const snapshot = makeSnapshot({ + incidents: [makeIncident("inc-1", { componentIds: [fixtureComponentId(0)] })], + }) + + it("tags components and incidents with their ids only when the preview flag is set", () => { + const html = renderPage(snapshot, live, FIXTURE_NOW, { preview: true }) + expect(html).toContain(`data-component-id="${fixtureComponentId(0)}"`) + expect(html).toContain(`data-incident-id="inc-1"`) + const publicHtml = renderPage(snapshot, live, FIXTURE_NOW) + expect(publicHtml).not.toMatch(/data-(component|incident)-id=/u) + }) + + it("adds nothing else: without the ids the preview is the public page", () => { + const preview = renderPage(snapshot, live, FIXTURE_NOW, { preview: true }) + expect(preview.replaceAll(/ data-(component|incident)-id="[^"]*"/gu, "")).toBe( + renderPage(snapshot, live, FIXTURE_NOW), + ) + }) +}) + describe("previewSnapshot", () => { const base = makeSnapshot() + const withoutDays = (index: number) => { + const { days: _days, ...component } = base.components[index]! + return component + } it("derives statuses from monitors, overrides and active incidents, with empty bars", () => { const incident = makeIncident("inc-1", { componentIds: [fixtureComponentId(1)], impact: "major" }) @@ -683,9 +708,9 @@ describe("previewSnapshot", () => { pageId: base.pageId, page: { ...base.page, theme: { ...DEFAULT_STATUS_PAGE_THEME, accent: "#123abc" } }, components: [ - { ...base.components[0]!, monitorStatus: "down", manualStatus: null }, - { ...base.components[1]!, monitorStatus: "up", manualStatus: null }, - { ...base.components[2]!, monitorStatus: null, manualStatus: "degraded" }, + { ...withoutDays(0), monitorStatus: "down", manualStatus: null }, + { ...withoutDays(1), monitorStatus: "up", manualStatus: null }, + { ...withoutDays(2), monitorStatus: null, manualStatus: "degraded" }, ], incidents: makeSnapshot({ incidents: [incident] }).incidents, nowMs: FIXTURE_NOW, @@ -704,3 +729,19 @@ describe("previewSnapshot", () => { expect(rowOf(html, snapshot.components[0]!.id)).toContain(`No data`) }) }) + +describe("previewSnapshot with history", () => { + it("shows the days it is given for a monitored component", () => { + const base = makeSnapshot() + const days = base.components[0]!.days + const snapshot = previewSnapshot({ + pageId: base.pageId, + page: base.page, + components: [{ ...base.components[0]!, monitorStatus: "up", manualStatus: null, days }], + incidents: [], + nowMs: FIXTURE_NOW, + }) + expect(snapshot.components[0]?.days).toEqual(days) + expect(renderPage(snapshot, live, FIXTURE_NOW)).toContain("100% uptime") + }) +}) From 01f201c3c0f00dd297f9a55f0c38a1507aa0fc4f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 23:35:50 +0100 Subject: [PATCH 03/12] feat(web): status pages get their own sidebar entry and list, with redirects from the Alerts hub /status-pages lists the pages, /status-pages/new and /status-pages/ follow. The old /alerts?tab=status-pages and /alerts/status-pages/ links redirect. Public page links use the status Worker's base URL (MAPLE_STATUS_PAGE_BASE_URL), a /s/ path until it is set. --- .../web/src/page/status-pages/list/command.ts | 27 ++ apps/web/src/page/status-pages/list/index.ts | 18 + .../page/status-pages/list/list.scene.test.ts | 124 ++++++ .../web/src/page/status-pages/list/message.ts | 14 + apps/web/src/page/status-pages/list/model.ts | 14 + apps/web/src/page/status-pages/list/update.ts | 40 ++ apps/web/src/page/status-pages/list/view.ts | 220 ++++++++++ .../src/page/status-pages/shared/commands.ts | 230 ++++++++++ .../page/status-pages/shared/derive.test.ts | 43 ++ .../src/page/status-pages/shared/derive.ts | 122 ++++++ .../src/page/status-pages/shared/fields.ts | 119 ++++++ .../src/page/status-pages/shared/form.test.ts | 111 +++++ apps/web/src/page/status-pages/shared/form.ts | 401 ++++++++++++++++++ .../src/page/status-pages/shared/slug.test.ts | 13 + apps/web/src/page/status-pages/shared/slug.ts | 28 ++ .../page/status-pages/shared/test-pages.ts | 127 ++++++ apps/web/src/route/app-route.ts | 3 + apps/web/src/route/redirect.ts | 14 +- apps/web/src/route/route.test.ts | 30 +- apps/web/src/route/routers.ts | 11 + apps/web/src/route/search.ts | 7 +- apps/web/src/shell/nav.ts | 1 + apps/web/src/ui/icons.generated.ts | 144 +++++++ apps/web/src/view.lazy.test.ts | 4 +- apps/web/src/worker.ts | 5 + apps/web/vite.config.ts | 2 + packages/infra/src/env.ts | 2 + 27 files changed, 1863 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/page/status-pages/list/command.ts create mode 100644 apps/web/src/page/status-pages/list/index.ts create mode 100644 apps/web/src/page/status-pages/list/list.scene.test.ts create mode 100644 apps/web/src/page/status-pages/list/message.ts create mode 100644 apps/web/src/page/status-pages/list/model.ts create mode 100644 apps/web/src/page/status-pages/list/update.ts create mode 100644 apps/web/src/page/status-pages/list/view.ts create mode 100644 apps/web/src/page/status-pages/shared/commands.ts create mode 100644 apps/web/src/page/status-pages/shared/derive.test.ts create mode 100644 apps/web/src/page/status-pages/shared/derive.ts create mode 100644 apps/web/src/page/status-pages/shared/fields.ts create mode 100644 apps/web/src/page/status-pages/shared/form.test.ts create mode 100644 apps/web/src/page/status-pages/shared/form.ts create mode 100644 apps/web/src/page/status-pages/shared/slug.test.ts create mode 100644 apps/web/src/page/status-pages/shared/slug.ts create mode 100644 apps/web/src/page/status-pages/shared/test-pages.ts diff --git a/apps/web/src/page/status-pages/list/command.ts b/apps/web/src/page/status-pages/list/command.ts new file mode 100644 index 0000000000..132403554d --- /dev/null +++ b/apps/web/src/page/status-pages/list/command.ts @@ -0,0 +1,27 @@ +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { readIsAdmin } from "../../alerts/shared/commands" +import { listPageSummaries } from "../shared/commands" +import { Message } from "./message" + +export const ReadListClock = Command.define("ReadStatusPagesClock", { + messages: [Message.ReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), +}) + +export const FetchListSession = Command.define("FetchStatusPagesSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readIsAdmin, (isAdmin) => Message.CompletedFetchSession({ orgId, isAdmin })), +}) + +export const FetchStatusPages = Command.define("FetchStatusPageSummaries", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchPages], + execute: ({ key }) => + listPageSummaries.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchPages({ key, result })), + ), +}) diff --git a/apps/web/src/page/status-pages/list/index.ts b/apps/web/src/page/status-pages/list/index.ts new file mode 100644 index 0000000000..28df1c8d9a --- /dev/null +++ b/apps/web/src/page/status-pages/list/index.ts @@ -0,0 +1,18 @@ +import { definePage } from "../../contract" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +/** `/status-pages`: every status page of the org. */ +export const page = definePage( + "StatusPages", + { Model, Message }, + { + routes: ["StatusPages"], + init, + update, + view, + breadcrumbs: () => [{ label: "Status pages", isPageHeading: true }], + }, +) diff --git a/apps/web/src/page/status-pages/list/list.scene.test.ts b/apps/web/src/page/status-pages/list/list.scene.test.ts new file mode 100644 index 0000000000..0eb3b146f2 --- /dev/null +++ b/apps/web/src/page/status-pages/list/list.scene.test.ts @@ -0,0 +1,124 @@ +import { OrgId } from "@maple/domain" +import { Result, Schema } from "effect" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { toLoadFailure } from "../../../data/load-failure" +import type { PageContext } from "../../contract" +import { NOW, ORG } from "../../alerts/shared/test-rows" +import { newStatusPageHref, statusPageHref } from "../shared/commands" +import type { StatusPageSummary } from "../shared/derive" +import { statusPage } from "../shared/test-pages" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as listView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const listUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(listView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + (commands ?? []).map(({ name }) => name) + +const entered = (isAdmin = true): Model => { + const start = init({ _tag: "StatusPages" }, context).model + return [Message.ReadClock({ nowMs: NOW }), Message.CompletedFetchSession({ orgId: ORG, isAdmin })].reduce( + (model, message) => listUpdate(model, message).model, + start, + ) +} + +const withPages = (pages: ReadonlyArray, isAdmin = true): Model => { + const model = entered(isAdmin) + return listUpdate( + model, + Message.CompletedFetchPages({ key: model.pages.key ?? "", result: Result.succeed(pages) }), + ).model +} + +describe("status pages list", () => { + test("entering reads the pages and the session", () => { + const sent = names(init({ _tag: "StatusPages" }, context).commands) + expect(sent).toContain("FetchStatusPageSummaries") + expect(sent).toContain("FetchStatusPagesSession") + }) + + test("an org without pages gets the builder as its on-ramp; members are told who can", () => { + scene( + { update: listUpdate, view }, + given(withPages([])), + expectView(text("No status pages yet")).toExist(), + expectView(role("link", { name: "Create a status page" })).toHaveAttr( + "href", + newStatusPageHref(), + ), + ) + scene( + { update: listUpdate, view }, + given(withPages([], false)), + expectView(role("link", { name: "Create a status page" })).not.toExist(), + expectView(text("Ask an org admin to create one.")).toExist(), + ) + }) + + test("a row shows the name, address, status, open incidents and publish state; drafts say so", () => { + const live: StatusPageSummary = { + page: statusPage({ published_at: null }), + overall: "partial_outage", + componentCount: 3, + openIncidents: 2, + } + const draft: StatusPageSummary = { + page: statusPage({ name: "Internal", slug: "acme-internal", draft: true, published_at: null }), + overall: "operational", + componentCount: 1, + openIncidents: 0, + } + scene( + { update: listUpdate, view }, + given(withPages([live, draft])), + expectView(role("link", { name: "Acme Status" })).toHaveAttr( + "href", + statusPageHref(live.page.id), + ), + expectView(text("/s/acme-status")).toExist(), + expectView(text("Partial outage")).toExist(), + expectView(text("2")).toExist(), + expectView(text("Not published")).toExist(), + expectView(text("Draft")).toExist(), + expectView(role("link", { name: "New status page" })).toHaveAttr("href", newStatusPageHref()), + ) + }) + + test("members get no New status page button", () => { + scene( + { update: listUpdate, view }, + given( + withPages( + [{ page: statusPage(), overall: "operational", componentCount: 1, openIncidents: 0 }], + false, + ), + ), + expectView(role("link", { name: "New status page" })).not.toExist(), + ) + }) + + test("a failed read offers a retry that reads again", () => { + const model = entered() + const failed = listUpdate( + model, + Message.CompletedFetchPages({ + key: model.pages.key ?? "", + result: Result.fail(toLoadFailure(new Error("down"))), + }), + ).model + scene( + { update: listUpdate, view }, + given(failed), + expectView(text("Couldn't load status pages")).toExist(), + ) + expect(names(listUpdate(failed, Message.ClickedRetry()).commands)).toEqual([ + "FetchStatusPageSummaries", + ]) + }) +}) diff --git a/apps/web/src/page/status-pages/list/message.ts b/apps/web/src/page/status-pages/list/message.ts new file mode 100644 index 0000000000..d1cdb73a48 --- /dev/null +++ b/apps/web/src/page/status-pages/list/message.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "../../../data/load-failure" +import { StatusPageSummary } from "../shared/derive" + +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const Message = defineMessageUnion({ + ReadClock: { nowMs: Schema.Number }, + CompletedFetchSession: { orgId: Schema.String, isAdmin: Schema.Boolean }, + CompletedFetchPages: { key: Schema.String, result: outcome(Schema.Array(StatusPageSummary)) }, + ClickedRetry: {}, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/status-pages/list/model.ts b/apps/web/src/page/status-pages/list/model.ts new file mode 100644 index 0000000000..974a2a853a --- /dev/null +++ b/apps/web/src/page/status-pages/list/model.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect" +import { KeyedSchema } from "../../../data/keyed" +import { LoadFailure } from "../../../data/load-failure" +import { StatusPageSummary } from "../shared/derive" + +// `/status-pages`: the org's pages with their state. + +export const Model = Schema.Struct({ + /** `statusPages.list` with each page's components and open incidents. */ + pages: KeyedSchema(Schema.Array(StatusPageSummary), LoadFailure), + nowMs: Schema.NullOr(Schema.Number), + isAdmin: Schema.Boolean, +}) +export type Model = typeof Model.Type diff --git a/apps/web/src/page/status-pages/list/update.ts b/apps/web/src/page/status-pages/list/update.ts new file mode 100644 index 0000000000..06420a3730 --- /dev/null +++ b/apps/web/src/page/status-pages/list/update.ts @@ -0,0 +1,40 @@ +import { Option } from "effect" +import * as Keyed from "../../../data/keyed" +import { requestKey } from "../../../data/request-key" +import type { AppRoute } from "../../../route" +import type { PageContext, PageReturn } from "../../contract" +import { FetchListSession, FetchStatusPages, ReadListClock } from "./command" +import { Message } from "./message" +import type { Model } from "./model" + +export type Return = PageReturn + +type ListRoute = Extract + +const pagesKey = (context: PageContext) => requestKey(context.orgId, "statusPages.summaries", {}) + +const request = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => + Option.match(Keyed.requestFor(model.pages, pagesKey(context), mode), { + onNone: () => ({ model }), + onSome: (pages) => ({ model: { ...model, pages }, commands: [FetchStatusPages({ key: pages.key })] }), + }) + +export const init = (_route: ListRoute, context: PageContext): Return => { + const read = request({ pages: Keyed.idle(), nowMs: null, isAdmin: false }, context, "Load") + return { + model: read.model, + commands: [ReadListClock(), FetchListSession({ orgId: context.orgId }), ...(read.commands ?? [])], + } +} + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + ReadClock: ({ nowMs }) => ({ model: { ...model, nowMs } }), + CompletedFetchSession: ({ orgId, isAdmin }) => + orgId === context.orgId ? { model: { ...model, isAdmin } } : { model }, + CompletedFetchPages: ({ key, result }) => ({ + model: { ...model, pages: Keyed.accept(model.pages, key, result) }, + commands: [ReadListClock()], + }), + ClickedRetry: () => request(model, context, "Supersede"), + }) diff --git a/apps/web/src/page/status-pages/list/view.ts b/apps/web/src/page/status-pages/list/view.ts new file mode 100644 index 0000000000..bb0b57c3d2 --- /dev/null +++ b/apps/web/src/page/status-pages/list/view.ts @@ -0,0 +1,220 @@ +import { countLabel } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { linkButton } from "../../../ui/button" +import { badge } from "../../../ui/display" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../ui/empty" +import { errorState } from "../../../ui/error-state" +import { eyebrow } from "../../../ui/eyebrow" +import { icon } from "../../../ui/icon" +import { skeleton } from "../../../ui/skeleton" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { relativeTime } from "../../kit/relative-time" +import { emptyActions } from "../../kit/signal-empty-state" +import { ROW_STRETCHED_LINK_CLASS } from "../../alerts/shared/links" +import { newStatusPageHref, publicUrl, statusPageHref } from "../shared/commands" +import { COMPONENT_STATUS_LABEL, COMPONENT_STATUS_TONE, type StatusPageSummary } from "../shared/derive" +import { Message } from "./message" +import type { Model } from "./model" + +// `/status-pages`: the page table, and the builder's on-ramp when there is none. + +type H = HtmlBuilder + +type ViewInputs = Readonly<{ isAdmin: boolean; nowMs: number; timeZone: string }> + +const ROW_GRID = + "grid grid-cols-[minmax(220px,1.6fr)_minmax(130px,0.8fr)_96px_112px_128px] items-center gap-x-5" + +/** No page yet: what a status page is, and the builder. */ +const emptyState = (h: H, isAdmin: boolean): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "WindowChartLineIcon", { size: 18 })]), + emptyTitle(h, ["No status pages yet"]), + emptyDescription(h, [ + "A public page that tells your customers what is up. Components follow your uptime monitors, incidents are written by you, and the page stays online when Maple is not.", + ]), + ]), + ...(isAdmin + ? [ + emptyActions(h, [ + linkButton(h, { size: "sm", href: newStatusPageHref() }, [ + icon(h, "PlusIcon"), + "Create a status page", + ]), + ]), + ] + : [h.p([h.Class("text-xs text-muted-foreground")], ["Ask an org admin to create one."])]), + ], + "py-12", + ) + +const lastPublished = (h: H, summary: StatusPageSummary, inputs: ViewInputs): Html => { + const page = summary.page + if (page.draft) return h.span([h.Class("flex justify-end")], [badge(h, { variant: "meta" }, ["Draft"])]) + if (page.last_publish_error !== null) + return h.span( + [h.Class("text-right text-xs text-destructive"), h.Title(page.last_publish_error)], + ["Publish failed"], + ) + return page.published_at === null + ? h.span([h.Class("text-right text-xs text-muted-foreground")], ["Not published"]) + : relativeTime(h, { + value: page.published_at, + nowMs: inputs.nowMs, + timeZone: inputs.timeZone, + tooltip: "title", + className: "text-right text-xs text-muted-foreground", + }) +} + +const pageRow = (h: H, summary: StatusPageSummary, inputs: ViewInputs): Html => { + const page = summary.page + return h.li( + [h.Key(page.id), h.Class("relative py-3 pr-4 pl-4 hover:bg-muted/25")], + [ + h.div( + [h.Class(ROW_GRID)], + [ + h.div( + [h.Class("min-w-0")], + [ + h.a( + [ + h.Href(statusPageHref(page.id)), + h.Class( + cn("block truncate text-sm font-medium", ROW_STRETCHED_LINK_CLASS), + ), + ], + [page.name], + ), + h.p( + [h.Class("truncate font-mono text-xs text-muted-foreground")], + [publicUrl(page.slug)], + ), + ], + ), + h.span( + [], + [ + summary.componentCount === 0 + ? h.span([h.Class("text-xs text-muted-foreground")], ["No components"]) + : badge(h, { variant: COMPONENT_STATUS_TONE[summary.overall] }, [ + COMPONENT_STATUS_LABEL[summary.overall], + ]), + ], + ), + h.span( + [h.Class("text-right font-mono text-xs tabular-nums text-muted-foreground")], + [String(summary.componentCount)], + ), + h.span( + [ + h.Class( + cn( + "text-right font-mono text-xs tabular-nums", + summary.openIncidents > 0 + ? "text-severity-warn" + : "text-muted-foreground", + ), + ), + ], + [String(summary.openIncidents)], + ), + lastPublished(h, summary, inputs), + ], + ), + ], + ) +} + +const table = (h: H, pages: ReadonlyArray, inputs: ViewInputs): Html => + h.div( + [h.Class("overflow-x-auto rounded-md border")], + [ + h.div( + [h.Class("min-w-[720px]")], + [ + h.div( + [h.Class(cn(ROW_GRID, "border-b py-2 pr-4 pl-4"))], + [ + eyebrow(h, {}, ["Page"]), + eyebrow(h, {}, ["Status"]), + eyebrow(h, { className: "text-right" }, ["Components"]), + eyebrow(h, { className: "text-right" }, ["Open incidents"]), + eyebrow(h, { className: "text-right" }, ["Last published"]), + ], + ), + h.ul( + [h.Class("divide-y")], + pages.map((summary) => pageRow(h, summary, inputs)), + ), + ], + ), + ], + ) + +const body = (h: H, model: Model, inputs: ViewInputs): Html => { + const data = model.pages.data + if (AsyncData.isFailure(data)) + return errorState(h, { + failure: data.error, + title: "Couldn't load status pages", + onRetry: Message.ClickedRetry(), + className: "py-12", + }) + return Option.match(AsyncData.getData(data), { + onNone: () => + h.div([h.Class("flex flex-col gap-4")], [skeleton(h, "h-9 w-1/3"), skeleton(h, "h-48 w-full")]), + onSome: (pages) => + pages.length === 0 + ? emptyState(h, inputs.isAdmin) + : h.div( + [h.Class("flex flex-col gap-3")], + [ + h.p( + [h.Class("text-xs text-muted-foreground")], + [ + `${countLabel(pages.length, "page")}. Each one is served from edge storage, so it stays up when Maple does not.`, + ], + ), + table(h, pages, inputs), + ], + ), + }) +} + +const hasPages = (model: Model): boolean => + Option.match(AsyncData.getData(model.pages.data), { + onNone: () => false, + onSome: (pages) => pages.length > 0, + }) + +export const view = Submodel.defineView((model, inputs, h) => + dashboardPage( + h, + { + title: "Status pages", + actions: + model.isAdmin && hasPages(model) + ? linkButton(h, { size: "sm", href: newStatusPageHref() }, [ + icon(h, "PlusIcon"), + "New status page", + ]) + : undefined, + }, + [ + body(h, model, { + isAdmin: model.isAdmin, + nowMs: model.nowMs ?? 0, + timeZone: inputs.context.timeZone, + }), + ], + ), +) diff --git a/apps/web/src/page/status-pages/shared/commands.ts b/apps/web/src/page/status-pages/shared/commands.ts new file mode 100644 index 0000000000..f86bde08f9 --- /dev/null +++ b/apps/web/src/page/status-pages/shared/commands.ts @@ -0,0 +1,230 @@ +import { type StatusPageComponentId, StatusPageId, type StatusPageIncidentId } from "@maple/domain" +import { + type StatusPageConfig, + type StatusPagePreviewParams, + StatusPagePublicId, + type V2StatusPageComponentCreateParams, + type V2StatusPageCreateParams, + type V2StatusPageIncidentCreateParams, + type V2StatusPageIncidentPostUpdateParams, + type V2StatusPageIncidentUpdateParams, + type V2StatusPageUpdateParams, +} from "@maple/domain/http/v2" +import { Effect, Option, Schema } from "effect" +import { toLoadFailure } from "../../../data/load-failure" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { AppRoute, hrefOf } from "../../../route" +import { listMonitors } from "../../alerts/shared/uptime-commands" +import { summarizePage } from "./derive" + +// The status page reads and writes, as Effects the pages' Commands wrap, and their links. + +/** One page of each list: far past any plan's allowance of pages, components or open incidents. */ +const LIST_LIMIT = 100 +/** Recent incidents the editor lists: the open ones plus the latest closed. */ +const INCIDENT_LIMIT = 50 + +const listPages = Effect.gen(function* () { + const client = yield* MapleApiV2Client + return (yield* client.statusPages.list({ query: { limit: LIST_LIMIT } })).data +}) + +const listPageComponents = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return (yield* client.statusPages.listComponents({ params: { id }, query: { limit: LIST_LIMIT } })) + .data + }) + +const listOpenIncidents = Effect.gen(function* () { + const client = yield* MapleApiV2Client + return (yield* client.statusPageIncidents.list({ query: { state: "open", limit: LIST_LIMIT } })).data +}) + +/** The list's rows: each page with its components' worst status and its open incident count. */ +export const listPageSummaries = Effect.gen(function* () { + const pages = yield* listPages + if (pages.length === 0) return [] + const [incidents, monitors, components] = yield* Effect.all( + [ + listOpenIncidents, + // A failed monitor read fails the tab: without it every monitored component would read green. + listMonitors, + Effect.forEach(pages, (page) => listPageComponents(page.id), { concurrency: 4 }), + ], + { concurrency: "unbounded" }, + ) + return pages.map((page, index) => + summarizePage({ + page, + components: components[index] ?? [], + incidents: incidents.filter((incident) => incident.page_id === page.id), + monitors, + }), + ) +}).pipe(Effect.mapError(toLoadFailure)) + +export const retrievePage = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.retrieve({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const listComponents = (id: StatusPageId) => + listPageComponents(id).pipe(Effect.mapError(toLoadFailure)) + +/** The page's incidents and maintenance, open and closed, newest first. */ +export const listPageIncidents = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const response = yield* client.statusPageIncidents.list({ + query: { page_id: id, state: "all", limit: INCIDENT_LIMIT }, + }) + return response.data + }).pipe(Effect.mapError(toLoadFailure)) + +/** The snapshot a draft would publish, with its monitors' real history; writes nothing. */ +export const previewPage = (payload: StatusPagePreviewParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.preview({ payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** Creates the page with its components in one write; `publish` makes it public at once. */ +export const createPage = (payload: V2StatusPageCreateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.create({ payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const updatePage = (id: StatusPageId, payload: V2StatusPageUpdateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.update({ params: { id }, payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deletePage = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.statusPages.delete({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const publishPage = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.publish({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const rotateBreakGlass = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.rotateBreakGlass({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const revokeBreakGlass = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.revokeBreakGlass({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** Adds a component, or updates `componentId`. */ +export const saveComponent = ( + id: StatusPageId, + componentId: StatusPageComponentId | null, + payload: V2StatusPageComponentCreateParams, +) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return componentId === null + ? yield* client.statusPages.createComponent({ params: { id }, payload }) + : yield* client.statusPages.updateComponent({ + params: { id, component_id: componentId }, + payload, + }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deleteComponent = (id: StatusPageId, componentId: StatusPageComponentId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.statusPages.deleteComponent({ params: { id, component_id: componentId } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const reorderComponents = (id: StatusPageId, componentIds: ReadonlyArray) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const response = yield* client.statusPages.reorderComponents({ + params: { id }, + payload: { component_ids: componentIds }, + }) + return response.data + }).pipe(Effect.mapError(toLoadFailure)) + +/** The page as one document, with the revision it was read at. */ +export const retrievePageConfig = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.getConfig({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** + * Replaces the page with `config` in one write and returns the applied document. A page past + * `ifVersion` refuses it if the config changed since, so a stale document never deletes what + * someone else added. + */ +export const applyPageConfig = (id: StatusPageId, config: StatusPageConfig, ifVersion: string | undefined) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.applyConfig({ + params: { id }, + query: { if_version: ifVersion }, + payload: config, + }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const createIncident = (payload: V2StatusPageIncidentCreateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPageIncidents.create({ payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const updateIncident = (id: StatusPageIncidentId, payload: V2StatusPageIncidentUpdateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPageIncidents.update({ params: { id }, payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const postIncidentUpdate = (id: StatusPageIncidentId, payload: V2StatusPageIncidentPostUpdateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPageIncidents.postUpdate({ params: { id }, payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +// LINKS + +const encodePublicId = Schema.encodeSync(StatusPagePublicId) +const decodePublicId = Schema.decodeUnknownOption(StatusPagePublicId) +const decodeInternalId = Schema.decodeUnknownOption(StatusPageId) + +/** The `stp_…` id in a URL, or a raw UUID, as the internal id. */ +export const pageIdFromUrl = (raw: string): Option.Option => + Option.orElse(decodePublicId(raw), () => decodeInternalId(raw)) + +/** The editor's views: the page itself (none), its settings, its JSON, or the just-published screen. */ +export type EditorView = "settings" | "json" | "live" + +export const statusPageHref = (id: StatusPageId, view?: EditorView): string => + hrefOf( + AppRoute.StatusPage({ + pageId: encodePublicId(id), + search: view === undefined ? {} : { view }, + }), + ) + +export const statusPagesHref = (): string => hrefOf(AppRoute.StatusPages()) + +export const newStatusPageHref = (): string => hrefOf(AppRoute.StatusPageNew()) + +const STATUS_BASE_URL: string = (import.meta.env.VITE_STATUS_PAGE_BASE_URL ?? "").trim().replace(/\/+$/u, "") + +/** Where visitors read the page: the status Worker's `/s/`, a bare path until its host is set. */ +export const publicUrl = (slug: string): string => `${STATUS_BASE_URL}/s/${slug}` diff --git a/apps/web/src/page/status-pages/shared/derive.test.ts b/apps/web/src/page/status-pages/shared/derive.test.ts new file mode 100644 index 0000000000..33452e56ef --- /dev/null +++ b/apps/web/src/page/status-pages/shared/derive.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest" +import { component, componentId, incident, statusPage } from "./test-pages" +import { summarizePage } from "./derive" +import { downMonitor, httpMonitor } from "../../alerts/shared/uptime-test-monitors" + +describe("status page summary", () => { + test("a component whose monitor is down puts the page in a major outage", () => { + const down = downMonitor() + const summary = summarizePage({ + page: statusPage(), + components: [component(1), component(2, { monitor_id: down.id })], + incidents: [], + monitors: [httpMonitor(), down], + }) + expect(summary.overall).toBe("major_outage") + expect(summary.componentCount).toBe(2) + }) + + test("a draft incident counts as open but does not change what visitors see", () => { + const summary = summarizePage({ + page: statusPage(), + components: [component(1)], + incidents: [incident(1, { published: false, impact: "critical" })], + monitors: [], + }) + expect(summary.overall).toBe("operational") + expect(summary.openIncidents).toBe(1) + }) + + test("a published incident degrades only the components it names, and a resolved one is not open", () => { + const summary = summarizePage({ + page: statusPage(), + components: [component(1), component(2)], + incidents: [ + incident(1, { impact: "minor", component_ids: [componentId(2)] }), + incident(2, { status: "resolved", impact: "critical" }), + ], + monitors: [], + }) + expect(summary.overall).toBe("degraded") + expect(summary.openIncidents).toBe(1) + }) +}) diff --git a/apps/web/src/page/status-pages/shared/derive.ts b/apps/web/src/page/status-pages/shared/derive.ts new file mode 100644 index 0000000000..a91b19b12f --- /dev/null +++ b/apps/web/src/page/status-pages/shared/derive.ts @@ -0,0 +1,122 @@ +import { + StatusPageComponentStatus, + type StatusPageIncidentImpact, + type StatusPageIncidentKind, + type StatusPageIncidentStatus, +} from "@maple/domain" +import { + V2StatusPage, + type V2StatusPageComponent, + type V2StatusPageIncident, + type V2UptimeMonitor, +} from "@maple/domain/http/v2" +import { + deriveComponentStatus, + incidentComponentStatus, + isActiveIncident, + worstStatus, +} from "@maple/domain/status-page" +import type { Tone } from "@maple/ui/lib/tone" +import { Schema } from "effect" + +// Pure reads of status pages shared by the hub's tab and the editor. + +/** A hub row: the page, its components' worst status and its open incidents. */ +export const StatusPageSummary = Schema.Struct({ + page: V2StatusPage, + overall: StatusPageComponentStatus, + componentCount: Schema.Number, + openIncidents: Schema.Number, +}) +export type StatusPageSummary = typeof StatusPageSummary.Type + +export const COMPONENT_STATUS_LABEL = { + operational: "Operational", + maintenance: "Maintenance", + degraded: "Degraded", + partial_outage: "Partial outage", + major_outage: "Major outage", +} as const satisfies Record + +/** The statuses a component can be set to by hand, best first. */ +export const COMPONENT_STATUS_OPTIONS: ReadonlyArray = [ + "operational", + "degraded", + "partial_outage", + "major_outage", + "maintenance", +] + +export const COMPONENT_STATUS_TONE = { + operational: "ok", + maintenance: "info", + degraded: "warn", + partial_outage: "warn", + major_outage: "crit", +} as const satisfies Record + +export const IMPACT_LABEL = { + none: "None", + minor: "Minor", + major: "Major", + critical: "Critical", +} as const satisfies Record + +export const INCIDENT_STATUS_LABEL = { + investigating: "Investigating", + identified: "Identified", + monitoring: "Monitoring", + resolved: "Resolved", + scheduled: "Scheduled", + in_progress: "In progress", + completed: "Completed", +} as const satisfies Record + +/** The statuses an incident or a maintenance moves through, the closing one last. */ +export const STATUSES_OF_KIND = { + incident: ["investigating", "identified", "monitoring", "resolved"], + maintenance: ["scheduled", "in_progress", "completed"], +} as const satisfies Record> + +export const closingStatus = (kind: StatusPageIncidentKind): StatusPageIncidentStatus => + kind === "incident" ? "resolved" : "completed" + +/** Not yet resolved or completed. */ +export const isOpenIncident = (incident: V2StatusPageIncident): boolean => + incident.status !== "resolved" && incident.status !== "completed" + +/** What a visitor sees for one component: monitor, override and published active incidents. */ +export const componentStatus = ( + component: V2StatusPageComponent, + monitors: ReadonlyArray, + incidents: ReadonlyArray, +): StatusPageComponentStatus => + deriveComponentStatus({ + monitorStatus: monitors.find((monitor) => monitor.id === component.monitor_id)?.state.status ?? null, + manualStatus: component.manual_status, + incidentStatuses: incidents + .filter( + (incident) => + incident.published && + isActiveIncident(incident) && + incident.component_ids.includes(component.id), + ) + .map(incidentComponentStatus), + }) + +export const summarizePage = (input: { + readonly page: V2StatusPage + readonly components: ReadonlyArray + readonly incidents: ReadonlyArray + readonly monitors: ReadonlyArray +}): StatusPageSummary => ({ + page: input.page, + overall: worstStatus( + input.components.map((component) => componentStatus(component, input.monitors, input.incidents)), + ), + componentCount: input.components.length, + openIncidents: input.incidents.filter(isOpenIncident).length, +}) + +/** Whether visitors see an older revision than the page has now. */ +export const hasUnpublishedChanges = (page: V2StatusPage): boolean => page.published_revision < page.revision diff --git a/apps/web/src/page/status-pages/shared/fields.ts b/apps/web/src/page/status-pages/shared/fields.ts new file mode 100644 index 0000000000..fc4c674f5c --- /dev/null +++ b/apps/web/src/page/status-pages/shared/fields.ts @@ -0,0 +1,119 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { input } from "../../../ui/input" +import { label } from "../../../ui/label" +import { nativeOption, nativeSelect } from "../../../ui/native-select" +import { textarea } from "../../../ui/textarea" + +// The labelled inputs the status page forms share: label over control, hint or error under it. + +type Common = Readonly<{ + id: string + label: string + hint?: string + /** Replaces the hint, in the destructive colour. */ + error?: string | null + className?: string + isDisabled?: boolean + /** Kept for screen readers when a row already names the control. */ + isLabelHidden?: boolean + onInput: (value: string) => M +}> + +const frame = (h: HtmlBuilder, options: Common, control: Html): Html => { + const error = options.error ?? null + return h.div( + [h.Class(cn("flex flex-col gap-1.5", options.className))], + [ + label( + h, + { + className: options.isLabelHidden === true ? "sr-only" : "text-xs text-muted-foreground", + attributes: [h.For(options.id)], + }, + [options.label], + ), + control, + ...(error !== null + ? [h.p([h.Id(`${options.id}-error`), h.Class("text-xs text-destructive")], [error])] + : options.hint === undefined + ? [] + : [h.p([h.Class("text-xs text-muted-foreground")], [options.hint])]), + ], + ) +} + +export const textField = ( + h: HtmlBuilder, + options: Common & + Readonly<{ + value: string + placeholder?: string + type?: string + isMono?: boolean + /** Commits the field: the editor saves as you go. */ + onBlur?: M + }>, +): Html => + frame( + h, + options, + input(h, { + id: options.id, + type: options.type, + isDisabled: options.isDisabled, + controlClassName: options.isMono === true ? "font-mono" : undefined, + attributes: [ + h.Value(options.value), + h.OnInput(options.onInput), + ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), + ...(options.onBlur === undefined ? [] : [h.OnBlur(options.onBlur)]), + ...((options.error ?? null) === null + ? [] + : [h.AriaInvalid(true), h.AriaDescribedBy(`${options.id}-error`)]), + ], + }), + ) + +export const textareaField = ( + h: HtmlBuilder, + options: Common & Readonly<{ value: string; placeholder?: string }>, +): Html => + frame( + h, + options, + textarea(h, { + id: options.id, + isDisabled: options.isDisabled, + attributes: [ + h.Value(options.value), + h.OnInput(options.onInput), + ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), + ], + }), + ) + +export const selectField = ( + h: HtmlBuilder, + options: Common & + Readonly<{ value: string; entries: ReadonlyArray<{ value: string; label: string }> }>, +): Html => + frame( + h, + options, + nativeSelect( + h, + { + defaultValue: options.value, + className: "w-full", + isDisabled: options.isDisabled, + attributes: [ + // Uncontrolled: keyed on the value so a reset or a reopened form rebuilds it. + h.Key(`${options.id}-${options.value}`), + h.Id(options.id), + h.OnChange(options.onInput), + ], + }, + options.entries.map((entry) => nativeOption(entry.value, entry.label)), + ), + ) diff --git a/apps/web/src/page/status-pages/shared/form.test.ts b/apps/web/src/page/status-pages/shared/form.test.ts new file mode 100644 index 0000000000..ca1d0601d1 --- /dev/null +++ b/apps/web/src/page/status-pages/shared/form.test.ts @@ -0,0 +1,111 @@ +import { DEFAULT_STATUS_PAGE_THEME } from "@maple/domain/status-page" +import { describe, expect, it } from "vitest" +import { + hasSettingsIssues, + isSameSettings, + type SettingsForm, + settingsIssues, + settingsPayload, + themeFormOf, +} from "./form" + +const saved: SettingsForm = { + name: "Acme!", + slug: "acme", + description: "", + websiteUrl: "https://acme.example", + privacyUrl: "", + termsUrl: "", + logoUrl: "", + showPoweredBy: true, + autoIncidents: "off", + theme: themeFormOf({ ...DEFAULT_STATUS_PAGE_THEME, accent: "#aabbcc" }), +} + +describe("isSameSettings", () => { + it("treats a draft the API stored trimmed and lower-cased as saved", () => { + const draft = { + ...saved, + name: "Acme! ", + websiteUrl: " https://acme.example", + theme: { ...saved.theme, accent: " #AABBCC" }, + } + expect(isSameSettings(draft, saved)).toBe(true) + }) + + it("still sees a real change", () => { + expect(isSameSettings({ ...saved, theme: { ...saved.theme, accent: "#aabbcd" } }, saved)).toBe(false) + expect(isSameSettings({ ...saved, theme: { ...saved.theme, radius: "lg" } }, saved)).toBe(false) + }) +}) + +describe("footer links", () => { + it("sends https links trimmed and blank ones as null", () => { + const payload = settingsPayload({ + ...saved, + privacyUrl: " https://acme.example/privacy ", + termsUrl: " ", + }) + expect(payload.privacy_url).toBe("https://acme.example/privacy") + expect(payload.terms_url).toBeNull() + }) + + it("refuses links the API would refuse", () => { + const issues = settingsIssues({ + ...saved, + privacyUrl: "http://acme.example/privacy", + termsUrl: "javascript:alert(1)", + }) + expect(issues.privacyUrl).toBe("Use an https URL.") + expect(issues.termsUrl).toBe("Use an https URL.") + expect(hasSettingsIssues({ ...saved, termsUrl: "https://acme.example/terms" })).toBe(false) + }) + + it("sees a changed link as unsaved", () => { + expect(isSameSettings({ ...saved, termsUrl: "https://acme.example/terms" }, saved)).toBe(false) + expect(isSameSettings({ ...saved, termsUrl: " " }, saved)).toBe(true) + }) +}) + +describe("URL fields", () => { + it("refuses what the service refuses: unparseable or not https, on every URL field", () => { + const issues = settingsIssues({ + ...saved, + websiteUrl: "http://acme.example", + privacyUrl: "https://acme.example:abc", + termsUrl: "https://acme.example/terms", + logoUrl: "", + }) + expect(issues.websiteUrl).toBe("Use an https URL.") + expect(issues.privacyUrl).toBe("Use a valid URL, like https://acme.example.") + expect(issues.termsUrl).toBeNull() + expect(issues.logoUrl).toBeNull() + }) + + it("refuses a link longer than the service stores", () => { + const long = `https://acme.example/${"a".repeat(2048)}` + expect(settingsIssues({ ...saved, termsUrl: long }).termsUrl).toBe("Keep it under 2048 characters.") + }) +}) + +describe("theme settings", () => { + it("sends the whole theme with blank colours as null", () => { + const form: SettingsForm = { + ...saved, + theme: { ...saved.theme, appearance: "dark", background: "#0B0B0B " }, + } + expect(settingsPayload(form).theme).toEqual({ + ...DEFAULT_STATUS_PAGE_THEME, + appearance: "dark", + accent: "#aabbcc", + background: "#0b0b0b", + }) + }) + + it("blocks a bad hex and unreadable text, but not overrides Auto ignores", () => { + expect(hasSettingsIssues({ ...saved, theme: { ...saved.theme, card: "teal" } })).toBe(true) + const lowContrast = { ...saved.theme, text: "#777777", background: "#ffffff" } + expect(hasSettingsIssues({ ...saved, theme: { ...lowContrast, appearance: "light" } })).toBe(true) + expect(hasSettingsIssues({ ...saved, theme: lowContrast })).toBe(false) + }) +}) diff --git a/apps/web/src/page/status-pages/shared/form.ts b/apps/web/src/page/status-pages/shared/form.ts new file mode 100644 index 0000000000..9eba786c3a --- /dev/null +++ b/apps/web/src/page/status-pages/shared/form.ts @@ -0,0 +1,401 @@ +import { + StatusPageAutoIncidents, + StatusPageComponentId, + StatusPageComponentStatus, + StatusPageIncidentId, + StatusPageIncidentImpact, + StatusPageIncidentKind, + StatusPageIncidentStatus, + UptimeMonitorId, +} from "@maple/domain" +import type { + V2StatusPage, + V2StatusPageComponent, + V2StatusPageComponentCreateParams, + V2StatusPageIncidentCreateParams, + V2StatusPageUpdateParams, +} from "@maple/domain/http/v2" +import { + STATUS_PAGE_HEX_COLOR_PATTERN, + StatusPageThemeAppearance, + StatusPageThemeFont, + StatusPageThemePreset, + StatusPageThemeRadius, + type StatusPageTheme, + themeContrastIssue, + STATUS_PAGE_MAX_URL_LENGTH, +} from "@maple/domain/status-page" +import { DateTime, Option, Schema } from "effect" +import { closingStatus, STATUSES_OF_KIND } from "./derive" +import { slugIssue } from "./slug" + +// The editor's drafts: each form as its inputs hold it, and the payloads built from them. + +// COMPONENTS + +export const ComponentTextField = Schema.Literals([ + "name", + "description", + "group", + "monitorId", + "manualStatus", +]) +export type ComponentTextField = typeof ComponentTextField.Type + +export const ComponentForm = Schema.Struct({ + /** The component being edited; `null` adds one. */ + editingId: Schema.NullOr(StatusPageComponentId), + name: Schema.String, + description: Schema.String, + group: Schema.String, + /** The linked monitor's id, `""` for none. */ + monitorId: Schema.String, + /** The override, `""` to follow the monitor and incidents. */ + manualStatus: Schema.String, + showUptime: Schema.Boolean, +}) +export type ComponentForm = typeof ComponentForm.Type + +export const emptyComponentForm: ComponentForm = { + editingId: null, + name: "", + description: "", + group: "", + monitorId: "", + manualStatus: "", + showUptime: true, +} + +export const componentFormOf = (component: V2StatusPageComponent): ComponentForm => ({ + editingId: component.id, + name: component.name, + description: component.description ?? "", + group: component.group ?? "", + monitorId: component.monitor_id ?? "", + manualStatus: component.manual_status ?? "", + showUptime: component.show_uptime, +}) + +const orNull = (value: string): string | null => (value.trim() === "" ? null : value.trim()) +const decodeMonitorId = Schema.decodeUnknownOption(UptimeMonitorId) +const decodeComponentStatus = Schema.decodeUnknownOption(StatusPageComponentStatus) + +/** The whole component, sent on create and update alike so a cleared field is cleared. */ +export const componentPayload = (form: ComponentForm): V2StatusPageComponentCreateParams => ({ + name: form.name.trim(), + description: orNull(form.description), + group: orNull(form.group), + monitor_id: Option.getOrNull(decodeMonitorId(form.monitorId)), + manual_status: Option.getOrNull(decodeComponentStatus(form.manualStatus)), + show_uptime: form.showUptime, +}) + +// INCIDENTS + +export const IncidentTextField = Schema.Literals([ + "title", + "impact", + "status", + "body", + "scheduledStart", + "scheduledEnd", +]) +export type IncidentTextField = typeof IncidentTextField.Type + +export const IncidentForm = Schema.Struct({ + kind: StatusPageIncidentKind, + title: Schema.String, + impact: StatusPageIncidentImpact, + status: StatusPageIncidentStatus, + componentIds: Schema.Array(StatusPageComponentId), + body: Schema.String, + /** `false` saves a draft visitors do not see. */ + isPublished: Schema.Boolean, + /** Maintenance only: the window as `datetime-local` values, in the viewer's time zone. */ + scheduledStart: Schema.String, + scheduledEnd: Schema.String, +}) +export type IncidentForm = typeof IncidentForm.Type + +export const emptyIncidentForm = (kind: StatusPageIncidentKind): IncidentForm => ({ + kind, + title: "", + impact: kind === "incident" ? "minor" : "none", + status: kind === "incident" ? "investigating" : "scheduled", + componentIds: [], + body: "", + isPublished: true, + scheduledStart: "", + scheduledEnd: "", +}) + +const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/u + +/** A `datetime-local` value read as wall-clock time in `timeZone`, as an ISO instant. */ +export const localToIso = (value: string, timeZone: string): Option.Option => { + const match = LOCAL_DATE_TIME.exec(value) + if (match === null) return Option.none() + const [year, month, day, hour, minute] = match.slice(1).map(Number) + return Option.map( + DateTime.makeZoned({ year, month, day, hour, minute }, { timeZone, adjustForTimeZone: true }), + DateTime.formatIso, + ) +} + +/** What stops the incident from being sent, in reading order; empty when it can go. */ +export const incidentIssues = (form: IncidentForm, timeZone: string): ReadonlyArray => { + const start = localToIso(form.scheduledStart, timeZone) + const end = localToIso(form.scheduledEnd, timeZone) + const windowIssue = + form.kind !== "maintenance" + ? null + : Option.isNone(start) || Option.isNone(end) + ? "Set when the maintenance starts and ends." + : start.value >= end.value + ? "The maintenance must end after it starts." + : null + return [ + ...(form.title.trim() === "" ? ["Add a title."] : []), + ...(form.body.trim() === "" ? ["Write the first update."] : []), + ...(windowIssue === null ? [] : [windowIssue]), + ] +} + +export const incidentPayload = ( + form: IncidentForm, + pageId: V2StatusPage["id"], + timeZone: string, +): V2StatusPageIncidentCreateParams => { + const base: V2StatusPageIncidentCreateParams = { + page_id: pageId, + kind: form.kind, + title: form.title.trim(), + impact: form.impact, + status: form.status, + body: form.body.trim(), + component_ids: form.componentIds, + published: form.isPublished, + } + if (form.kind !== "maintenance") return base + return Option.match( + Option.all([localToIso(form.scheduledStart, timeZone), localToIso(form.scheduledEnd, timeZone)]), + { + onNone: () => base, + onSome: ([start, end]) => ({ ...base, scheduled_start: start, scheduled_end: end }), + }, + ) +} + +// UPDATES + +export const UpdateForm = Schema.Struct({ + incidentId: StatusPageIncidentId, + kind: StatusPageIncidentKind, + status: StatusPageIncidentStatus, + body: Schema.String, +}) +export type UpdateForm = typeof UpdateForm.Type + +/** The next status along, or the closing one for Resolve. */ +export const updateFormFor = ( + incident: { + readonly id: StatusPageIncidentId + readonly kind: StatusPageIncidentKind + readonly status: StatusPageIncidentStatus + }, + isResolving: boolean, +): UpdateForm => { + const statuses: ReadonlyArray = STATUSES_OF_KIND[incident.kind] + const next = statuses[statuses.indexOf(incident.status) + 1] ?? incident.status + const status = isResolving ? closingStatus(incident.kind) : next + return { + incidentId: incident.id, + kind: incident.kind, + status, + body: isResolving + ? incident.kind === "incident" + ? "This incident has been resolved." + : "The maintenance is complete." + : "", + } +} + +// SETTINGS + +export const SettingsTextField = Schema.Literals([ + "name", + "slug", + "description", + "websiteUrl", + "privacyUrl", + "termsUrl", + "logoUrl", + "autoIncidents", +]) +export type SettingsTextField = typeof SettingsTextField.Type + +/** The theme's colour inputs, as typed; blank means the preset's colour. */ +export const ThemeColorField = Schema.Literals(["accent", "background", "text", "card", "border"]) +export type ThemeColorField = typeof ThemeColorField.Type + +/** The colours Auto ignores: they apply only to a forced Light or Dark page. */ +export const THEME_OVERRIDE_FIELDS = ["background", "text", "card", "border"] as const + +export const ThemeForm = Schema.Struct({ + preset: StatusPageThemePreset, + appearance: StatusPageThemeAppearance, + accent: Schema.String, + background: Schema.String, + text: Schema.String, + card: Schema.String, + border: Schema.String, + radius: StatusPageThemeRadius, + font: StatusPageThemeFont, +}) +export type ThemeForm = typeof ThemeForm.Type + +export const SettingsForm = Schema.Struct({ + name: Schema.String, + slug: Schema.String, + description: Schema.String, + websiteUrl: Schema.String, + privacyUrl: Schema.String, + termsUrl: Schema.String, + logoUrl: Schema.String, + showPoweredBy: Schema.Boolean, + autoIncidents: StatusPageAutoIncidents, + theme: ThemeForm, +}) +export type SettingsForm = typeof SettingsForm.Type + +export const themeFormOf = (theme: StatusPageTheme): ThemeForm => ({ + ...theme, + accent: theme.accent ?? "", + background: theme.background ?? "", + text: theme.text ?? "", + card: theme.card ?? "", + border: theme.border ?? "", +}) + +export const settingsFormOf = (page: V2StatusPage): SettingsForm => ({ + name: page.name, + slug: page.slug, + description: page.description ?? "", + websiteUrl: page.website_url ?? "", + privacyUrl: page.privacy_url ?? "", + termsUrl: page.terms_url ?? "", + logoUrl: page.logo_url ?? "", + showPoweredBy: page.show_powered_by, + autoIncidents: page.auto_incidents, + theme: themeFormOf(page.theme), +}) + +const normalizedColor = (value: string): string => value.trim().toLowerCase() + +/** The theme as the API stores it: blank colours null, the rest trimmed and lower-cased. */ +export const themeOf = (form: ThemeForm): StatusPageTheme => ({ + ...form, + accent: orNull(normalizedColor(form.accent)), + background: orNull(normalizedColor(form.background)), + text: orNull(normalizedColor(form.text)), + card: orNull(normalizedColor(form.card)), + border: orNull(normalizedColor(form.border)), +}) + +/** The values the API stores: trimmed, the colours lower-cased, as `settingsPayload` sends them. */ +const normalizedSettings = (form: SettingsForm): SettingsForm => ({ + ...form, + name: form.name.trim(), + description: form.description.trim(), + websiteUrl: form.websiteUrl.trim(), + privacyUrl: form.privacyUrl.trim(), + termsUrl: form.termsUrl.trim(), + logoUrl: form.logoUrl.trim(), + theme: themeFormOf(themeOf(form.theme)), +}) + +/** Same once normalized, so a saved draft whose input differed only in case or spaces settles. */ +export const isSameSettings = (left: SettingsForm, right: SettingsForm): boolean => + sameNormalized(normalizedSettings(left), normalizedSettings(right)) + +const sameNormalized = (a: SettingsForm, b: SettingsForm): boolean => + a.name === b.name && + a.slug === b.slug && + a.description === b.description && + a.websiteUrl === b.websiteUrl && + a.privacyUrl === b.privacyUrl && + a.termsUrl === b.termsUrl && + a.logoUrl === b.logoUrl && + a.showPoweredBy === b.showPoweredBy && + a.autoIncidents === b.autoIncidents && + sameTheme(a.theme, b.theme) + +const sameTheme = (a: ThemeForm, b: ThemeForm): boolean => + a.preset === b.preset && + a.appearance === b.appearance && + a.accent === b.accent && + a.background === b.background && + a.text === b.text && + a.card === b.card && + a.border === b.border && + a.radius === b.radius && + a.font === b.font +/** The service's rule: a URL `URL` can parse, over https. Checked here so Save never sends what it refuses. */ +const httpsIssue = (value: string): string | null => { + const url = value.trim() + if (url === "") return null + if (url.length > STATUS_PAGE_MAX_URL_LENGTH) + return `Keep it under ${STATUS_PAGE_MAX_URL_LENGTH} characters.` + if (!URL.canParse(url)) return "Use a valid URL, like https://acme.example." + return new URL(url).protocol === "https:" ? null : "Use an https URL." +} + +/** Each field's problem, or `null`: the API refuses the same values. */ +export const settingsIssues = (form: SettingsForm): Readonly> => ({ + name: form.name.trim() === "" ? "Add a name." : null, + slug: slugIssue(form.slug), + description: null, + websiteUrl: httpsIssue(form.websiteUrl), + privacyUrl: httpsIssue(form.privacyUrl), + termsUrl: httpsIssue(form.termsUrl), + logoUrl: httpsIssue(form.logoUrl), + autoIncidents: null, +}) + +const colorIssue = (value: string): string | null => + value.trim() === "" || STATUS_PAGE_HEX_COLOR_PATTERN.test(value.trim()) + ? null + : "Use a hex color like #1a2b3c." + +/** Each colour input's problem, or `null`. */ +export const themeColorIssues = (theme: ThemeForm): Readonly> => ({ + accent: colorIssue(theme.accent), + background: colorIssue(theme.background), + text: colorIssue(theme.text), + card: colorIssue(theme.card), + border: colorIssue(theme.border), +}) + +/** Unreadable text, as the API would refuse it; checked once every colour is a valid hex. */ +export const themeContrastOf = (theme: ThemeForm): string | null => + Object.values(themeColorIssues(theme)).some((issue) => issue !== null) + ? null + : themeContrastIssue(themeOf(theme)) + +export const hasSettingsIssues = (form: SettingsForm): boolean => + Object.values(settingsIssues(form)).some((issue) => issue !== null) || + Object.values(themeColorIssues(form.theme)).some((issue) => issue !== null) || + themeContrastOf(form.theme) !== null + +export const settingsPayload = (form: SettingsForm): V2StatusPageUpdateParams => ({ + name: form.name.trim(), + slug: form.slug, + description: orNull(form.description), + website_url: orNull(form.websiteUrl), + privacy_url: orNull(form.privacyUrl), + terms_url: orNull(form.termsUrl), + logo_url: orNull(form.logoUrl), + theme: themeOf(form.theme), + show_powered_by: form.showPoweredBy, + auto_incidents: form.autoIncidents, +}) diff --git a/apps/web/src/page/status-pages/shared/slug.test.ts b/apps/web/src/page/status-pages/shared/slug.test.ts new file mode 100644 index 0000000000..cbc5061717 --- /dev/null +++ b/apps/web/src/page/status-pages/shared/slug.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest" +import { slugFromName } from "./slug" + +describe("slugFromName", () => { + it("keeps accented letters as their base letter instead of splitting them with hyphens", () => { + expect(slugFromName("Crème Brûlée")).toBe("creme-brulee") + expect(slugFromName("Über Status")).toBe("uber-status") + }) + + it("collapses punctuation and trims hyphens at the ends", () => { + expect(slugFromName(" Acme Status! ")).toBe("acme-status") + }) +}) diff --git a/apps/web/src/page/status-pages/shared/slug.ts b/apps/web/src/page/status-pages/shared/slug.ts new file mode 100644 index 0000000000..0b1dc72017 --- /dev/null +++ b/apps/web/src/page/status-pages/shared/slug.ts @@ -0,0 +1,28 @@ +import { StatusPageSlug } from "@maple/domain/status-page" +import { Schema } from "effect" + +// The page address: suggested from the name until typed, checked as the API checks it. + +const SLUG_MAX = 48 + +/** `Acme Status!` as `acme-status`: lowercase letters and digits, single inner hyphens. */ +export const slugFromName = (name: string): string => + name + .toLowerCase() + .normalize("NFKD") + // NFKD splits accented letters into base and mark; drop the mark so "Crème" stays "creme". + .replace(/\p{M}+/gu, "") + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, SLUG_MAX) + .replace(/-+$/u, "") + +const isSlug = Schema.is(StatusPageSlug) + +/** Why a slug would be refused, or `null` when it is fine. */ +export const slugIssue = (slug: string): string | null => + slug === "" + ? "Add an address." + : isSlug(slug) + ? null + : "Use 3 to 48 lowercase letters, digits or inner hyphens." diff --git a/apps/web/src/page/status-pages/shared/test-pages.ts b/apps/web/src/page/status-pages/shared/test-pages.ts new file mode 100644 index 0000000000..fd367156f0 --- /dev/null +++ b/apps/web/src/page/status-pages/shared/test-pages.ts @@ -0,0 +1,127 @@ +import { + StatusPageComponentId, + StatusPageId, + StatusPageIncidentId, + StatusPageIncidentUpdateId, +} from "@maple/domain" +import { DEFAULT_STATUS_PAGE_THEME } from "@maple/domain/status-page" +import { + type StatusPageConfig, + statusPageConfigVersion, + type V2StatusPage, + type V2StatusPageComponent, + type V2StatusPageIncident, +} from "@maple/domain/http/v2" +import { Schema } from "effect" +import { NOW } from "../../alerts/shared/test-rows" + +// Status pages as the v2 API decodes them, for the scene tests. + +const iso = (msBefore: number) => new Date(NOW - msBefore).toISOString() +const MINUTE = 60_000 + +export const pageId = (n: number) => + Schema.decodeSync(StatusPageId)(`0199a010-0000-7000-8000-00000000000${n}`) +export const componentId = (n: number) => + Schema.decodeSync(StatusPageComponentId)(`0199a011-0000-7000-8000-00000000000${n}`) +export const incidentId = (n: number) => + Schema.decodeSync(StatusPageIncidentId)(`0199a012-0000-7000-8000-00000000000${n}`) +const updateId = (n: number) => + Schema.decodeSync(StatusPageIncidentUpdateId)(`0199a013-0000-7000-8000-00000000000${n}`) + +export const statusPage = (fields: Partial = {}): V2StatusPage => ({ + id: pageId(1), + object: "status_page", + name: "Acme Status", + slug: "acme-status", + description: null, + website_url: null, + logo_url: null, + privacy_url: null, + terms_url: null, + theme: DEFAULT_STATUS_PAGE_THEME, + show_powered_by: true, + auto_incidents: "draft", + draft: false, + break_glass_token_suffix: null, + revision: 3, + published_revision: 3, + published_at: iso(5 * MINUTE), + last_publish_error: null, + created_at: iso(60 * MINUTE), + updated_at: iso(5 * MINUTE), + ...fields, +}) + +export const component = (n: number, fields: Partial = {}): V2StatusPageComponent => ({ + id: componentId(n), + object: "status_page_component", + page_id: pageId(1), + name: `Component ${n}`, + description: null, + group: null, + position: n - 1, + monitor_id: null, + manual_status: null, + show_uptime: true, + created_at: iso(60 * MINUTE), + updated_at: iso(60 * MINUTE), + ...fields, +}) + +export const incident = (n: number, fields: Partial = {}): V2StatusPageIncident => ({ + id: incidentId(n), + object: "status_page_incident", + page_id: pageId(1), + kind: "incident", + title: `Incident ${n}`, + impact: "major", + status: "investigating", + published: true, + scheduled_start: null, + scheduled_end: null, + started_at: iso(10 * MINUTE), + resolved_at: null, + component_ids: [componentId(1)], + updates: [ + { + id: updateId(n), + status: "investigating", + body: "We are looking into elevated error rates.", + source: "dashboard", + created_at: iso(10 * MINUTE), + }, + ], + created_at: iso(10 * MINUTE), + updated_at: iso(10 * MINUTE), + ...fields, +}) + +/** The document `GET …/config` returns for a page and its components. */ +export const statusPageConfig = ( + page: V2StatusPage, + components: ReadonlyArray, +): StatusPageConfig => { + const content: StatusPageConfig = { + name: page.name, + slug: page.slug, + description: page.description, + website_url: page.website_url, + logo_url: page.logo_url, + privacy_url: page.privacy_url, + terms_url: page.terms_url, + show_powered_by: page.show_powered_by, + auto_incidents: page.auto_incidents, + theme: page.theme, + components: components.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + group: row.group, + monitor_id: row.monitor_id, + manual_status: row.manual_status, + show_uptime: row.show_uptime, + })), + } + return { version: statusPageConfigVersion(content), ...content } +} diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 8425e76310..d318d4887b 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -35,6 +35,9 @@ export const AppRoute = defineRouteUnion({ IngestRuleNew: { search: Search.IngestRuleNewSearch }, IngestRule: { ruleId: Schema.String }, AlertsStatusPage: { pageId: Schema.String, search: Search.AlertsStatusPageSearch }, + StatusPages: {}, + StatusPageNew: {}, + StatusPage: { pageId: Schema.String, search: Search.StatusPageSearch }, Anomaly: { incidentId: AnomalyIncidentId }, CodeReviewCi: { search: Search.CodeReviewCiSearch }, CodeReviewIssues: { search: Search.CodeReviewIssuesSearch }, diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index 225c151fd9..d85a429f6e 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -40,7 +40,7 @@ const DATA_INTEGRATION_IDS: ReadonlySet = new Set(DATA_INTEGRATIONS) const redirect = (to: AppRoute, isReplace: boolean) => Option.some(Redirect.make({ url: hrefOf(to), isReplace })) -/** Redirects of retired paths (connectors, developer, infra, errors/issues) and moved integrations. */ +/** Redirects of retired paths (connectors, developer, infra, errors/issues, alerts status pages) and moved integrations. */ export const routeRedirect = (route: AppRoute): Option.Option => AppRoute.matchOrElse>( route, @@ -67,6 +67,18 @@ export const routeRedirect = (route: AppRoute): Option.Option => InfraDiscover: () => redirect(AppRoute.Infra({ search: {} }), true), InfraLegacyHost: ({ hostName }) => redirect(AppRoute.InfraHost({ hostName, search: {} }), true), AlertIncidentsIndex: () => redirect(AppRoute.Alerts({ search: {} }), true), + /** Status pages left the Alerts hub for their own sidebar entry. */ + Alerts: ({ search }) => + search.tab === "status-pages" ? redirect(AppRoute.StatusPages(), true) : Option.none(), + AlertsStatusPage: ({ pageId, search }) => + redirect( + AppRoute.StatusPage({ + pageId, + search: + search.tab === "settings" || search.tab === "json" ? { view: search.tab } : {}, + }), + true, + ), RecommendationsIndex: () => redirect(AppRoute.Settings({ search: { tab: "ingestion" } }), true), InfraKubernetes: ({ search }) => redirect(AppRoute.InfraKubernetesPods({ search }), true), ErrorIssues: (issues) => redirect(AppRoute.Errors({ search: errorsSearchOf(issues) }), true), diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index fa1b5454e8..8958bee397 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -40,14 +40,28 @@ describe("routes", () => { expect(hrefOf(monitor)).toBe("/alerts/uptime/upm_abc") }) - test("a status page route keeps its tab and prints back", () => { - const route = parse("/alerts/status-pages/stp_abc?tab=%22incidents%22") - expect(route).toMatchObject({ - _tag: "AlertsStatusPage", - pageId: "stp_abc", - search: { tab: "incidents" }, - }) - expect(hrefOf(route)).toBe("/alerts/status-pages/stp_abc?tab=incidents") + test("status page routes parse, the builder ahead of a page id, and print back", () => { + expect(parse("/status-pages")._tag).toBe("StatusPages") + expect(parse("/status-pages/new")._tag).toBe("StatusPageNew") + const route = parse("/status-pages/stp_abc?view=%22settings%22") + expect(route).toMatchObject({ _tag: "StatusPage", pageId: "stp_abc", search: { view: "settings" } }) + expect(hrefOf(route)).toBe("/status-pages/stp_abc?view=settings") + }) + + test("the old status page links under Alerts redirect to their own section", () => { + expect(routeRedirect(parse("/alerts?tab=%22status-pages%22"))).toEqual( + Option.some({ url: "/status-pages", isReplace: true }), + ) + expect(routeRedirect(parse("/alerts?tab=%22uptime%22"))).toEqual(Option.none()) + expect(routeRedirect(parse("/alerts/status-pages/stp_abc"))).toEqual( + Option.some({ url: "/status-pages/stp_abc", isReplace: true }), + ) + expect(routeRedirect(parse("/alerts/status-pages/stp_abc?tab=%22json%22"))).toEqual( + Option.some({ url: "/status-pages/stp_abc?view=json", isReplace: true }), + ) + expect(routeRedirect(parse("/alerts/status-pages/stp_abc?tab=%22incidents%22"))).toEqual( + Option.some({ url: "/status-pages/stp_abc", isReplace: true }), + ) }) test("a literal sibling wins over a param route", () => { diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index cd9a148ba8..74407ecf2a 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -96,12 +96,21 @@ const routers = { slash(segment("ruleId")), Route.mapTo(AppRoute.IngestRule), ), + // Retired: `routeRedirect` sends it to `/status-pages/$pageId`. AlertsStatusPage: pipe( path("alerts", "status-pages"), slash(segment("pageId")), search(Search.AlertsStatusPageSearch), Route.mapTo(AppRoute.AlertsStatusPage), ), + StatusPages: pipe(path("status-pages"), Route.mapTo(AppRoute.StatusPages)), + StatusPageNew: pipe(path("status-pages", "new"), Route.mapTo(AppRoute.StatusPageNew)), + StatusPage: pipe( + path("status-pages"), + slash(segment("pageId")), + search(Search.StatusPageSearch), + Route.mapTo(AppRoute.StatusPage), + ), Anomaly: pipe( path("anomalies"), slash(schemaSegment("incidentId", AnomalyIncidentId)), @@ -417,6 +426,8 @@ export const urlToAppRoute = Route.parseUrlWithFallback( routers.IngestRuleNew, routers.IngestRule, routers.AlertsStatusPage, + routers.StatusPageNew, + routers.StatusPage, routers.DashboardsTemplates, routers.SourcesNew, routers.InfraDiscover, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 01af1a391f..7449980fa9 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -337,11 +337,16 @@ export const IngestRuleNewSearch = Schema.Struct({ template: Schema.optional(Schema.String), }) -/** `/alerts/status-pages/$pageId`: `tab` is `components`, `incidents` or `settings`. */ +/** The retired `/alerts/status-pages/$pageId`: `tab` picks the view its redirect opens. */ export const AlertsStatusPageSearch = Schema.Struct({ tab: Schema.optional(Schema.String), }) +/** `/status-pages/$pageId`: `view` is `settings`, `json` or `live`; none is the page itself. */ +export const StatusPageSearch = Schema.Struct({ + view: Schema.optional(Schema.String), +}) + export const CodeReviewCiSearch = Schema.Struct(CodeReviewCiSearchFields) export const CodeReviewIssuesSearch = Schema.Struct(CodeReviewIssuesSearchFields) diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 264239c755..479eee0faf 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -138,6 +138,7 @@ export const navGroups = ( label: "Monitor", items: [ { ...servicesItem, alsoActive: flags.releases ? [] : ["/releases"] }, + { title: "Status pages", href: "/status-pages", icon: "WindowChartLineIcon" }, ...(flags.releases ? [{ title: "Releases", href: "/releases", icon: "RocketIcon" } as const] : []), diff --git a/apps/web/src/ui/icons.generated.ts b/apps/web/src/ui/icons.generated.ts index bc412bb967..29a60b3303 100644 --- a/apps/web/src/ui/icons.generated.ts +++ b/apps/web/src/ui/icons.generated.ts @@ -21896,6 +21896,150 @@ export const ICON_TREES = { ], ], ], + WindowChartLineIcon: [ + "svg", + [ + ["xmlns", "http://www.w3.org/2000/svg"], + ["viewBox", "0 0 24 24"], + ["width", "7777"], + ["height", "7777"], + ["class", "__icon_class__"], + ["fill", "none"], + ["aria-hidden", "true"], + ], + [ + [ + "path", + [ + ["d", "M6 8H6.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M10 8H10.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M4 4H20"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M4 20H20"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M2 6V18"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M22 6V18"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M8.00001 14H8.01001"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M10 12H10.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M12 14H12.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M14 16H14.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M16 14H16.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M18 12H18.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M6.00001 16H6.01001"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + ], + ], } as const satisfies Record export type IconName = keyof typeof ICON_TREES diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 67a59d0160..91ac902159 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -42,7 +42,9 @@ const SAMPLE_PATHS = [ "/alerts/create", "/alerts/uptime/new", "/alerts/uptime/upm_abc", - "/alerts/status-pages/stp_abc", + "/status-pages", + "/status-pages/new", + "/status-pages/stp_abc", "/service-map", "/infra/hosts", "/infra/hosts/web-1", diff --git a/apps/web/src/worker.ts b/apps/web/src/worker.ts index 6bee9c2c70..b08d3ec6dd 100644 --- a/apps/web/src/worker.ts +++ b/apps/web/src/worker.ts @@ -59,6 +59,11 @@ const props = Effect.gen(function* () { ? yield* plainFrom(["VITE_INGEST_URL"], "https://ingest.maple.dev") : urls.ingest, VITE_ELECTRIC_SYNC_URL: urls.electricSync, + // The status Worker's origin, for the public page links; blank leaves them as paths. + VITE_STATUS_PAGE_BASE_URL: yield* plainFrom( + ["VITE_STATUS_PAGE_BASE_URL", "MAPLE_STATUS_PAGE_BASE_URL"], + "", + ), // Orgs living in another region are redirected to that region's app. VITE_MAPLE_REGION: region, VITE_MAPLE_REGION_APP_URLS: JSON.stringify(resolveRegionAppUrls(stage)), diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index df74f9440c..1bda792d88 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -15,6 +15,7 @@ const overrideKeys = [ "VITE_INGEST_URL", "VITE_MAPLE_SELF_INGEST_URL", "VITE_ELECTRIC_SYNC_URL", + "VITE_STATUS_PAGE_BASE_URL", "VITE_MAPLE_REGION", "VITE_MAPLE_REGION_APP_URLS", "VITE_MAPLE_AUTH_MODE", @@ -107,6 +108,7 @@ export default defineConfig(({ mode, command }) => { process.env.VITE_API_BASE_URL ??= siblingUrl("api") process.env.VITE_INGEST_URL ??= siblingUrl("ingest") process.env.VITE_ELECTRIC_SYNC_URL ??= siblingUrl("electric-sync") + process.env.VITE_STATUS_PAGE_BASE_URL ??= siblingUrl("status") } process.env.VITE_MAPLE_AUTH_MODE ||= env.MAPLE_AUTH_MODE?.trim() || "self_hosted" process.env.VITE_CLERK_PUBLISHABLE_KEY ||= env.CLERK_PUBLISHABLE_KEY?.trim() || "" diff --git a/packages/infra/src/env.ts b/packages/infra/src/env.ts index f5ce6f0bd5..3a6a141fef 100644 --- a/packages/infra/src/env.ts +++ b/packages/infra/src/env.ts @@ -125,6 +125,8 @@ export const appUrlsEnv = (domains: MapleDomains = {}): Config.Config ? derived("MAPLE_API_BASE_URL", `https://${domains.api}`) : plainWithDefault("MAPLE_API_BASE_URL", "https://api.maple.dev"), plainWithDefault("EMAIL_FROM", "Maple "), + // Where the status Worker serves pages (`/s/`); unset until it has a domain. + optionalPlain("MAPLE_STATUS_PAGE_BASE_URL"), ) /** From 2f8296d5059df4933913865a0c76f09019379996 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 23:35:57 +0100 Subject: [PATCH 04/12] feat(web): one-screen status page builder with a live preview of real uptime Name and address, a checklist of the org's monitors (renamed, grouped and ordered in place), components updated by hand, a pasted URL that becomes an HTTP monitor, and the look, beside the public renderer's page with the monitors' real history. Publish or save a draft in one create. --- .../builder/builder.scene.test.ts | 336 +++++++++++++++ .../src/page/status-pages/builder/command.ts | 81 ++++ .../src/page/status-pages/builder/index.ts | 22 + .../src/page/status-pages/builder/message.ts | 39 ++ .../src/page/status-pages/builder/model.ts | 44 ++ .../src/page/status-pages/builder/update.ts | 292 +++++++++++++ .../web/src/page/status-pages/builder/view.ts | 338 +++++++++++++++ apps/web/src/page/status-pages/shared/look.ts | 88 ++++ .../page/status-pages/shared/picker-view.ts | 406 ++++++++++++++++++ .../src/page/status-pages/shared/picker.ts | 232 ++++++++++ .../src/page/status-pages/shared/preview.ts | 182 ++++++++ 11 files changed, 2060 insertions(+) create mode 100644 apps/web/src/page/status-pages/builder/builder.scene.test.ts create mode 100644 apps/web/src/page/status-pages/builder/command.ts create mode 100644 apps/web/src/page/status-pages/builder/index.ts create mode 100644 apps/web/src/page/status-pages/builder/message.ts create mode 100644 apps/web/src/page/status-pages/builder/model.ts create mode 100644 apps/web/src/page/status-pages/builder/update.ts create mode 100644 apps/web/src/page/status-pages/builder/view.ts create mode 100644 apps/web/src/page/status-pages/shared/look.ts create mode 100644 apps/web/src/page/status-pages/shared/picker-view.ts create mode 100644 apps/web/src/page/status-pages/shared/picker.ts create mode 100644 apps/web/src/page/status-pages/shared/preview.ts diff --git a/apps/web/src/page/status-pages/builder/builder.scene.test.ts b/apps/web/src/page/status-pages/builder/builder.scene.test.ts new file mode 100644 index 0000000000..069f90e64a --- /dev/null +++ b/apps/web/src/page/status-pages/builder/builder.scene.test.ts @@ -0,0 +1,336 @@ +import { OrgId } from "@maple/domain" +import { StatusPageSlugTakenError } from "@maple/domain/http" +import type { V2UptimeMonitor } from "@maple/domain/http/v2" +import { Option, Result, Schema } from "effect" +import { + attr, + expect as expectView, + find, + given, + role, + scene, + selector, + tap, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { toLoadFailure } from "../../../data/load-failure" +import { PageOutMessage, type PageContext } from "../../contract" +import { ORG } from "../../alerts/shared/test-rows" +import { + downMonitor, + heartbeatMonitor, + httpMonitor, + monitorId, +} from "../../alerts/shared/uptime-test-monitors" +import { statusPageHref } from "../shared/commands" +import { monitorKey, PickerMessage } from "../shared/picker" +import { statusPage } from "../shared/test-pages" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as builderView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const builderUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(builderView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + (commands ?? []).map(({ name }) => name) +const picker = (message: PickerMessage) => Message.GotPickerMessage({ message }) + +const tcpMonitor = () => + httpMonitor({ + id: monitorId(6), + name: "postgres", + kind: "tcp", + config: { type: "tcp", host: "db.acme.dev", port: 5432, timeout_ms: 10000 }, + }) + +const withMonitors = (monitors: ReadonlyArray, isAdmin = true): Model => { + const start = init({ _tag: "StatusPageNew" }, context).model + return [ + Message.ReadClock({ nowMs: Date.UTC(2026, 9, 1, 12) }), + Message.CompletedFetchSession({ orgId: ORG, isAdmin }), + Message.CompletedFetchMonitors({ key: start.monitors.key ?? "", result: Result.succeed(monitors) }), + ].reduce((model, message) => builderUpdate(model, message).model, start) +} + +const named = (model: Model, name = "Acme Status") => + builderUpdate(model, Message.ChangedName({ value: name })).model + +describe("status page builder", () => { + test("a small org starts with every monitor on the page except heartbeats", () => { + const model = withMonitors([httpMonitor(), downMonitor(), tcpMonitor(), heartbeatMonitor()]) + expect(model.picker.rows.map((row) => [row.name, row.isChecked])).toEqual([ + ["checkout-api", true], + ["payments-api", true], + ["postgres", true], + ["nightly-billing-export", false], + ]) + scene( + { update: builderUpdate, view }, + given(model), + expectView(text("api.acme.dev/checkout/health")).toExist(), + expectView(text("db.acme.dev:5432")).toExist(), + expectView(text("heartbeat")).toExist(), + expectView( + role("checkbox", { name: "Show nightly-billing-export on the page" }), + ).not.toBeChecked(), + ) + }) + + test("past eight monitors nothing starts checked", () => { + const many = Array.from({ length: 9 }, (_, index) => + httpMonitor({ id: monitorId(index), name: `m${index}` }), + ) + expect(withMonitors(many).picker.rows.some((row) => row.isChecked)).toBe(false) + }) + + test("the address follows the name until it is typed", () => { + const step = (model: Model, message: Message) => builderUpdate(model, message).model + const followed = step( + withMonitors([httpMonitor()]), + Message.ChangedName({ value: "Acme Cloud Status" }), + ) + expect(followed.slug).toBe("acme-cloud-status") + const typed = step( + step(followed, Message.ChangedSlug({ value: "status" })), + Message.ChangedName({ value: "Acme" }), + ) + scene( + { update: builderUpdate, view }, + given(typed), + expectView(role("textbox", { name: "Name" })).toHaveValue("Acme"), + expectView(role("textbox", { name: "Address" })).toHaveValue("status"), + ) + }) + + test("a checked row renames inline and moves among the checked rows", () => { + const model = withMonitors([httpMonitor(), downMonitor(), tcpMonitor()]) + const renamed = builderUpdate( + model, + picker(PickerMessage.ChangedRowName({ key: monitorKey(downMonitor().id), value: "Payments" })), + ).model + const movedUp = builderUpdate( + renamed, + picker(PickerMessage.ClickedMoveRow({ key: monitorKey(downMonitor().id), offset: -1 })), + ).model + expect(movedUp.picker.rows.map((row) => row.name)).toEqual(["Payments", "checkout-api", "postgres"]) + scene( + { update: builderUpdate, view }, + given(movedUp), + expectView(role("button", { name: "Move Payments up" })).toBeDisabled(), + expectView(role("button", { name: "Move postgres down" })).toBeDisabled(), + expectView(role("textbox", { name: "Shown as, payments-api" })).toHaveValue("Payments"), + ) + }) + + test("a pasted URL becomes an HTTP monitor, then a checked component", () => { + const opened = builderUpdate( + withMonitors([httpMonitor()]), + picker(PickerMessage.ClickedOpenUrl()), + ).model + const typed = builderUpdate(opened, picker(PickerMessage.ChangedUrl({ value: "api.acme.com" }))).model + const creating = builderUpdate(typed, picker(PickerMessage.SubmittedUrl())) + expect(creating.commands).toMatchObject([ + { name: "WaitStatusPageBuilderPreview" }, + { name: "CreateStatusPageMonitor", args: { url: "https://api.acme.com" } }, + ]) + const monitor = httpMonitor({ id: monitorId(8), name: "api.acme.com" }) + const created = builderUpdate( + creating.model, + Message.CompletedCreateMonitor({ result: Result.succeed(monitor) }), + ).model + expect(created.picker.rows.at(-1)).toMatchObject({ monitorId: monitor.id, isChecked: true }) + expect(created.picker.url).toBe("") + }) + + test("a URL the uptime form would refuse is refused here, without a create", () => { + const opened = builderUpdate( + withMonitors([httpMonitor()]), + picker(PickerMessage.ClickedOpenUrl()), + ).model + const typed = builderUpdate( + opened, + picker(PickerMessage.ChangedUrl({ value: "http://localhost:3000" })), + ).model + const refused = builderUpdate(typed, picker(PickerMessage.SubmittedUrl())) + expect(names(refused.commands)).not.toContain("CreateStatusPageMonitor") + expect(refused.model.picker.urlIssue).not.toBeNull() + }) + + test("with no monitors, the URL field and the add-by-hand row are the way in", () => { + scene( + { update: builderUpdate, view }, + given(withMonitors([])), + expectView(text("Paste a URL to monitor, or add a component you'll update by hand.")).toExist(), + expectView(role("textbox", { name: "URL to monitor" })).toExist(), + expectView(role("button", { name: "Add a component without a monitor" })).toExist(), + ) + }) + + test("a component without a monitor gets a name and a manual status, and its name field focus", () => { + const adding = builderUpdate(withMonitors([]), picker(PickerMessage.ClickedAddManual())) + expect(names(adding.commands)).toContain("FocusStatusPageBuilderField") + const row = adding.model.picker.rows[0] + const filled = [ + picker(PickerMessage.ChangedRowName({ key: row?.key ?? "", value: "Docs" })), + picker(PickerMessage.PickedRowStatus({ key: row?.key ?? "", value: "degraded" })), + ].reduce((model, message) => builderUpdate(model, message).model, named(adding.model)) + expect(builderUpdate(filled, Message.ClickedSubmit({ mode: "publish" })).commands).toMatchObject([ + { + name: "CreateStatusPage", + args: { + payload: { + components: [ + { name: "Docs", monitor_id: null, manual_status: "degraded", show_uptime: true }, + ], + }, + }, + }, + ]) + }) + + test("a taken address shows next to the field and blocks publishing until it changes", () => { + const model = named(withMonitors([httpMonitor()])) + const sending = builderUpdate(model, Message.ClickedSubmit({ mode: "publish" })).model + const refused = builderUpdate( + sending, + Message.CompletedCreate({ + mode: "publish", + result: Result.fail( + toLoadFailure(new StatusPageSlugTakenError({ slug: "acme-status", message: "taken" })), + ), + }), + ) + expect(names(refused.commands)).toContain("FocusStatusPageBuilderField") + scene( + { update: builderUpdate, view }, + given(refused.model), + expectView(text("That address is taken. Pick another.")).toExist(), + expectView(role("button", { name: "Publish page" })).toBeDisabled(), + ) + const changed = builderUpdate(refused.model, Message.ChangedSlug({ value: "acme-status-2" })).model + scene( + { update: builderUpdate, view }, + given(changed), + expectView(text("That address is taken. Pick another.")).not.toExist(), + expectView(role("button", { name: "Publish page" })).not.toBeDisabled(), + ) + expect( + builderUpdate(refused.model, Message.ClickedSubmit({ mode: "publish" })).commands ?? [], + ).toEqual([]) + }) + + test("an address the API would refuse says why and cannot be sent", () => { + const model = builderUpdate( + named(withMonitors([httpMonitor()])), + Message.ChangedSlug({ value: "a" }), + ).model + scene( + { update: builderUpdate, view }, + given(model), + expectView(text("Use 3 to 48 lowercase letters, digits or inner hyphens.")).toExist(), + expectView(role("button", { name: "Publish page" })).toBeDisabled(), + expectView(role("button", { name: "Save as draft" })).toBeDisabled(), + ) + }) + + test("Publish sends one create with the components in order and publish, then opens the live screen", () => { + const model = named(withMonitors([httpMonitor(), downMonitor(), heartbeatMonitor()])) + const sending = builderUpdate(model, Message.ClickedSubmit({ mode: "publish" })) + expect(sending.commands).toMatchObject([ + { + name: "CreateStatusPage", + args: { + mode: "publish", + payload: { + name: "Acme Status", + slug: "acme-status", + theme: { preset: "default", appearance: "auto" }, + components: [ + { name: "checkout-api", monitor_id: httpMonitor().id }, + { name: "payments-api", monitor_id: downMonitor().id }, + ], + publish: true, + }, + }, + }, + ]) + expect( + builderUpdate(sending.model, Message.ClickedSubmit({ mode: "publish" })).commands ?? [], + ).toEqual([]) + const page = statusPage() + const done = builderUpdate( + sending.model, + Message.CompletedCreate({ mode: "publish", result: Result.succeed(page) }), + ) + expect(done.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ url: statusPageHref(page.id, "live"), isReplace: true }), + ) + }) + + test("Save as draft sends publish false and opens the page, which shows it is a draft", () => { + const model = named(withMonitors([httpMonitor()])) + const sending = builderUpdate(model, Message.ClickedSubmit({ mode: "draft" })) + expect(sending.commands).toMatchObject([ + { name: "CreateStatusPage", args: { payload: { publish: false } } }, + ]) + const page = statusPage({ draft: true, published_at: null }) + const done = builderUpdate( + sending.model, + Message.CompletedCreate({ mode: "draft", result: Result.succeed(page) }), + ) + expect(done.outMessage).toMatchObject({ _tag: "RequestedNavigation", url: statusPageHref(page.id) }) + }) + + test("edits ask for a preview once they pause; an older wait or answer is dropped", () => { + const model = withMonitors([httpMonitor()]) + const first = builderUpdate(model, Message.ChangedName({ value: "A" })) + const second = builderUpdate(first.model, Message.ChangedName({ value: "Ac" })) + expect(names(second.commands)).toEqual(["WaitStatusPageBuilderPreview"]) + const generation = second.model.previewGeneration + expect( + builderUpdate(second.model, Message.ElapsedPreviewDebounce({ generation: generation - 1 })) + .commands ?? [], + ).toEqual([]) + const fetching = builderUpdate(second.model, Message.ElapsedPreviewDebounce({ generation })) + expect(fetching.commands).toMatchObject([ + { + name: "FetchStatusPagePreview", + args: { generation, draft: { name: "Ac", components: [{ monitor_id: httpMonitor().id }] } }, + }, + ]) + scene({ update: builderUpdate, view }, given(fetching.model), expectView(text("Updating")).toExist()) + }) + + test("members are told only admins create pages", () => { + scene( + { update: builderUpdate, view }, + given(withMonitors([httpMonitor()], false)), + expectView(text("Only org admins can create status pages")).toExist(), + expectView(role("button", { name: "Publish page" })).not.toExist(), + ) + }) + + test("the preview is the public renderer in a sandboxed frame, following the draft", () => { + const model = named(withMonitors([httpMonitor()]), "Acme Cloud") + const dark = builderUpdate(model, Message.PickedPreviewScheme({ scheme: "dark" })) + expect(dark.commands ?? []).toEqual([]) + scene( + { update: builderUpdate, view }, + given(dark.model), + expectView(selector("iframe")).toHaveAttr("sandbox", "allow-scripts"), + tap(({ html }) => { + const srcdoc = Option.getOrElse( + Option.flatMap(find("iframe")(html), attr("srcdoc")), + () => "", + ) + expect(srcdoc).toContain("Acme Cloud") + expect(srcdoc).toContain("color-scheme:dark;") + }), + ) + }) +}) diff --git a/apps/web/src/page/status-pages/builder/command.ts b/apps/web/src/page/status-pages/builder/command.ts new file mode 100644 index 0000000000..b6d0d5a3a7 --- /dev/null +++ b/apps/web/src/page/status-pages/builder/command.ts @@ -0,0 +1,81 @@ +import { StatusPagePreviewParams, V2StatusPageCreateParams } from "@maple/domain/http/v2" +import { Clock, Duration, Effect, Schema } from "effect" +import { Command, Dom } from "foldkit" +import { readIsAdmin } from "../../alerts/shared/commands" +import { listMonitors, saveMonitor } from "../../alerts/shared/uptime-commands" +import { createPage, previewPage } from "../shared/commands" +import { monitorPayloadFor } from "../shared/picker" +import { Message } from "./message" +import { SubmitMode } from "./model" + +/** How long the builder waits after the last edit before it asks for a new preview. */ +export const PREVIEW_DEBOUNCE_MS = 400 + +export const ReadBuilderClock = Command.define("ReadStatusPageBuilderClock", { + messages: [Message.ReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), +}) + +export const FetchBuilderSession = Command.define("FetchStatusPageBuilderSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readIsAdmin, (isAdmin) => Message.CompletedFetchSession({ orgId, isAdmin })), +}) + +export const FetchBuilderMonitors = Command.define("FetchStatusPageBuilderMonitors", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchMonitors], + execute: ({ key }) => + listMonitors.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMonitors({ key, result })), + ), +}) + +export const WaitBuilderPreview = Command.define("WaitStatusPageBuilderPreview", { + args: { generation: Schema.Number }, + messages: [Message.ElapsedPreviewDebounce], + execute: ({ generation }) => + Effect.as( + Effect.sleep(Duration.millis(PREVIEW_DEBOUNCE_MS)), + Message.ElapsedPreviewDebounce({ generation }), + ), +}) + +export const FetchBuilderPreview = Command.define("FetchStatusPagePreview", { + args: { generation: Schema.Number, draft: StatusPagePreviewParams }, + messages: [Message.CompletedFetchPreview], + execute: ({ generation, draft }) => + previewPage(draft).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchPreview({ generation, draft, result })), + ), +}) + +export const CreateBuilderMonitor = Command.define("CreateStatusPageMonitor", { + args: { url: Schema.String }, + messages: [Message.CompletedCreateMonitor], + execute: ({ url }) => + saveMonitor(null, monitorPayloadFor(url)).pipe( + Effect.result, + Effect.map((result) => Message.CompletedCreateMonitor({ result })), + ), +}) + +export const CreateBuilderPage = Command.define("CreateStatusPage", { + args: { mode: SubmitMode, payload: V2StatusPageCreateParams }, + messages: [Message.CompletedCreate], + execute: ({ mode, payload }) => + createPage(payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedCreate({ mode, result })), + ), +}) + +export const FocusBuilderField = Command.define("FocusStatusPageBuilderField", { + args: { id: Schema.String }, + messages: [Message.CompletedFocus], + execute: ({ id }) => + Dom.focus(`#${CSS.escape(id)}`).pipe(Effect.ignore, Effect.as(Message.CompletedFocus())), +}) diff --git a/apps/web/src/page/status-pages/builder/index.ts b/apps/web/src/page/status-pages/builder/index.ts new file mode 100644 index 0000000000..ae44afacce --- /dev/null +++ b/apps/web/src/page/status-pages/builder/index.ts @@ -0,0 +1,22 @@ +import { definePage } from "../../contract" +import { statusPagesHref } from "../shared/commands" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +/** `/status-pages/new`: the one-screen builder. */ +export const page = definePage( + "StatusPageNew", + { Model, Message }, + { + routes: ["StatusPageNew"], + init, + update, + view, + breadcrumbs: () => [ + { label: "Status pages", href: statusPagesHref() }, + { label: "New", isPageHeading: true }, + ], + }, +) diff --git a/apps/web/src/page/status-pages/builder/message.ts b/apps/web/src/page/status-pages/builder/message.ts new file mode 100644 index 0000000000..29d0230ff2 --- /dev/null +++ b/apps/web/src/page/status-pages/builder/message.ts @@ -0,0 +1,39 @@ +import { StatusPagePreviewParams, V2StatusPage, V2UptimeMonitor } from "@maple/domain/http/v2" +import { + StatusPageSnapshot, + StatusPageThemeAppearance, + StatusPageThemePreset, +} from "@maple/domain/status-page" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "../../../data/load-failure" +import { PickerMessage } from "../shared/picker" +import { PreviewScheme } from "../shared/preview" +import { SubmitMode } from "./model" + +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const Message = defineMessageUnion({ + ReadClock: { nowMs: Schema.Number }, + CompletedFetchSession: { orgId: Schema.String, isAdmin: Schema.Boolean }, + CompletedFetchMonitors: { key: Schema.String, result: outcome(Schema.Array(V2UptimeMonitor)) }, + ClickedRetry: {}, + ChangedName: { value: Schema.String }, + BlurredName: {}, + ChangedSlug: { value: Schema.String }, + GotPickerMessage: { message: PickerMessage }, + CompletedCreateMonitor: { result: outcome(V2UptimeMonitor) }, + CompletedFocus: {}, + PickedPreset: { preset: StatusPageThemePreset }, + PickedAppearance: { appearance: StatusPageThemeAppearance }, + PickedPreviewScheme: { scheme: PreviewScheme }, + ElapsedPreviewDebounce: { generation: Schema.Number }, + CompletedFetchPreview: { + generation: Schema.Number, + draft: StatusPagePreviewParams, + result: outcome(StatusPageSnapshot), + }, + ClickedSubmit: { mode: SubmitMode }, + CompletedCreate: { mode: SubmitMode, result: outcome(V2StatusPage) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/status-pages/builder/model.ts b/apps/web/src/page/status-pages/builder/model.ts new file mode 100644 index 0000000000..7f8867bef9 --- /dev/null +++ b/apps/web/src/page/status-pages/builder/model.ts @@ -0,0 +1,44 @@ +import { V2UptimeMonitor } from "@maple/domain/http/v2" +import { StatusPageThemeAppearance, StatusPageThemePreset } from "@maple/domain/status-page" +import { Schema } from "effect" +import { KeyedSchema } from "../../../data/keyed" +import { LoadFailure } from "../../../data/load-failure" +import { Picker } from "../shared/picker" +import { PreviewHistory, PreviewScheme } from "../shared/preview" + +// `/status-pages/new`: a page's name, address, components and look on one screen, beside the +// page as visitors will get it. + +/** Publish makes the page public at once; a draft stays private until published. */ +export const SubmitMode = Schema.Literals(["publish", "draft"]) +export type SubmitMode = typeof SubmitMode.Type + +export const Model = Schema.Struct({ + nowMs: Schema.NullOr(Schema.Number), + isAdmin: Schema.Boolean, + isSessionLoaded: Schema.Boolean, + monitors: KeyedSchema(Schema.Array(V2UptimeMonitor), LoadFailure), + /** The checklist is seeded from the first monitors read, then belongs to the user. */ + isSeeded: Schema.Boolean, + name: Schema.String, + slug: Schema.String, + /** The user typed an address; until then it follows the name. */ + isSlugEdited: Schema.Boolean, + isNameTouched: Schema.Boolean, + picker: Picker, + preset: StatusPageThemePreset, + appearance: StatusPageThemeAppearance, + previewScheme: PreviewScheme, + /** Bumped on every edit; a debounce or a preview answer from an older one is stale. */ + previewGeneration: Schema.Number, + /** The generation the shown history came from. */ + previewShown: Schema.Number, + previewHistory: PreviewHistory, + isPreviewLoading: Schema.Boolean, + previewFailure: Schema.NullOr(LoadFailure), + submitting: Schema.NullOr(SubmitMode), + createFailure: Schema.NullOr(LoadFailure), + /** An address the server refused as taken; it blocks publishing until changed. */ + takenSlug: Schema.NullOr(Schema.String), +}) +export type Model = typeof Model.Type diff --git a/apps/web/src/page/status-pages/builder/update.ts b/apps/web/src/page/status-pages/builder/update.ts new file mode 100644 index 0000000000..5511c65be1 --- /dev/null +++ b/apps/web/src/page/status-pages/builder/update.ts @@ -0,0 +1,292 @@ +import type { + StatusPagePreviewParams, + V2StatusPageCreateParams, + V2UptimeMonitor, +} from "@maple/domain/http/v2" +import { Option, Result } from "effect" +import { AsyncData } from "foldkit" +import * as Keyed from "../../../data/keyed" +import type { LoadFailure } from "../../../data/load-failure" +import { requestKey } from "../../../data/request-key" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { UPTIME_LIST_LIMIT } from "../../alerts/shared/uptime-commands" +import { statusPageHref } from "../shared/commands" +import { + draftComponents, + emptyPicker, + hasRowIssues, + monitorCreated, + monitorFailed, + seedPicker, + updatePicker, +} from "../shared/picker" +import { rowNameId } from "../shared/picker-view" +import { historyFrom } from "../shared/preview" +import { slugFromName, slugIssue } from "../shared/slug" +import { + CreateBuilderMonitor, + CreateBuilderPage, + FetchBuilderMonitors, + FetchBuilderPreview, + FetchBuilderSession, + FocusBuilderField, + ReadBuilderClock, + WaitBuilderPreview, +} from "./command" +import { Message } from "./message" +import type { Model, SubmitMode } from "./model" + +export type Return = PageReturn + +type BuilderRoute = Extract + +export const PICKER_ID = "status-page-builder" +export const NAME_ID = "status-page-builder-name" +export const SLUG_ID = "status-page-builder-slug" + +const SLUG_TAKEN = "status_page_slug_taken" + +const monitorsKey = (context: PageContext) => + requestKey(context.orgId, "uptimeMonitors.list", { query: { limit: UPTIME_LIST_LIMIT } }) + +const readMonitors = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => + Option.match(Keyed.requestFor(model.monitors, monitorsKey(context), mode), { + onNone: () => ({ model }), + onSome: (monitors) => ({ + model: { ...model, monitors }, + commands: [FetchBuilderMonitors({ key: monitors.key })], + }), + }) + +export const monitorsOf = (model: Model) => AsyncData.getOrElse(model.monitors.data, () => []) + +// ISSUES + +export const nameIssue = (model: Model): string | null => + model.isNameTouched && model.name.trim() === "" ? "Add a name." : null + +/** The address's problem: the server's "taken", or what the API would refuse. */ +export const slugError = (model: Model): string | null => + model.takenSlug !== null && model.takenSlug === model.slug + ? "That address is taken. Pick another." + : model.slug === "" && !model.isNameTouched + ? null + : slugIssue(model.slug) + +/** Every check a create must pass, so neither button sends a page the API would refuse. */ +export const canSubmit = (model: Model): boolean => + model.isAdmin && + model.submitting === null && + !model.picker.isCreatingMonitor && + model.name.trim() !== "" && + slugIssue(model.slug) === null && + model.takenSlug !== model.slug && + !hasRowIssues(model.picker) + +// DRAFT + +const themeOf = (model: Model) => ({ preset: model.preset, appearance: model.appearance }) + +export const previewDraft = (model: Model): StatusPagePreviewParams => ({ + name: model.name.trim(), + slug: model.slug, + theme: themeOf(model), + components: draftComponents(model.picker), +}) + +export const createPayload = (model: Model, mode: SubmitMode): V2StatusPageCreateParams => ({ + name: model.name.trim(), + slug: model.slug, + theme: themeOf(model), + components: draftComponents(model.picker), + publish: mode === "publish", +}) + +/** An edit: the preview is asked for again once the edits pause. */ +const edited = (model: Model): Return => { + const generation = model.previewGeneration + 1 + return { + model: { ...model, previewGeneration: generation }, + commands: [WaitBuilderPreview({ generation })], + } +} + +export const init = (_route: BuilderRoute, context: PageContext): Return => { + const model: Model = { + nowMs: null, + isAdmin: false, + isSessionLoaded: false, + monitors: Keyed.idle(), + isSeeded: false, + name: "", + slug: "", + isSlugEdited: false, + isNameTouched: false, + picker: emptyPicker, + preset: "default", + appearance: "auto", + previewScheme: "light", + previewGeneration: 0, + previewShown: 0, + previewHistory: [], + isPreviewLoading: false, + previewFailure: null, + submitting: null, + createFailure: null, + takenSlug: null, + } + const read = readMonitors(model, context, "Load") + return { + model: read.model, + commands: [ + ReadBuilderClock(), + FetchBuilderSession({ orgId: context.orgId }), + ...(read.commands ?? []), + ], + } +} + +/** The first monitors read fills the checklist; later reads (a retry) leave the user's picks. */ +const acceptMonitors = ( + model: Model, + key: string, + result: Result.Result, LoadFailure>, +): Return => { + const monitors = Keyed.accept(model.monitors, key, result) + const next = { ...model, monitors } + if (model.isSeeded || !AsyncData.hasData(monitors.data)) return { model: next } + return edited({ + ...next, + isSeeded: true, + picker: seedPicker( + AsyncData.getOrElse(monitors.data, () => []), + true, + ), + }) +} + +const pickerStep = (model: Model, message: Parameters[1]): Return => { + const step = updatePicker(model.picker, message) + const next = edited({ ...model, picker: step.picker }) + return { + model: next.model, + commands: [ + ...(next.commands ?? []), + ...Option.match(step.createMonitor, { + onNone: () => [], + onSome: (url) => [CreateBuilderMonitor({ url })], + }), + ...Option.match(step.focusKey, { + onNone: () => [], + onSome: (key) => [FocusBuilderField({ id: rowNameId(PICKER_ID, key) })], + }), + ], + } +} + +const submit = (model: Model, mode: SubmitMode): Return => + !canSubmit(model) + ? { model: { ...model, isNameTouched: true } } + : { + model: { ...model, submitting: mode, createFailure: null }, + commands: [CreateBuilderPage({ mode, payload: createPayload(model, mode) })], + } + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + ReadClock: ({ nowMs }) => ({ model: { ...model, nowMs } }), + CompletedFetchSession: ({ orgId, isAdmin }) => + orgId === context.orgId ? { model: { ...model, isAdmin, isSessionLoaded: true } } : { model }, + CompletedFetchMonitors: ({ key, result }) => acceptMonitors(model, key, result), + ClickedRetry: () => readMonitors(model, context, "Supersede"), + ChangedName: ({ value }) => + edited({ ...model, name: value, slug: model.isSlugEdited ? model.slug : slugFromName(value) }), + BlurredName: () => ({ model: { ...model, isNameTouched: true } }), + ChangedSlug: ({ value }) => { + const slug = value.trim().toLowerCase() + // Clearing the address hands it back to the name. + return edited({ + ...model, + slug: slug === "" ? slugFromName(model.name) : slug, + isSlugEdited: slug !== "", + }) + }, + GotPickerMessage: ({ message: child }) => pickerStep(model, child), + CompletedCreateMonitor: ({ result }) => + Result.match(result, { + onSuccess: (monitor): Return => + edited({ + ...model, + monitors: { + ...model.monitors, + data: AsyncData.map(model.monitors.data, (monitors) => [...monitors, monitor]), + }, + picker: monitorCreated(model.picker, monitor), + }), + onFailure: (failure): Return => ({ + model: { ...model, picker: monitorFailed(model.picker, failure.message) }, + }), + }), + CompletedFocus: () => ({ model }), + PickedPreset: ({ preset }) => edited({ ...model, preset }), + PickedAppearance: ({ appearance }) => edited({ ...model, appearance }), + PickedPreviewScheme: ({ scheme }) => ({ model: { ...model, previewScheme: scheme } }), + ElapsedPreviewDebounce: ({ generation }) => + generation !== model.previewGeneration + ? { model } + : { + model: { ...model, isPreviewLoading: true }, + commands: [FetchBuilderPreview({ generation, draft: previewDraft(model) })], + }, + // An answer older than the one shown is dropped; the newest keeps loading until it lands. + CompletedFetchPreview: ({ generation, draft, result }) => + generation < model.previewShown + ? { model } + : Result.match(result, { + onSuccess: (snapshot): Return => ({ + model: { + ...model, + previewShown: generation, + previewHistory: historyFrom(draft, snapshot), + isPreviewLoading: generation !== model.previewGeneration, + previewFailure: null, + }, + }), + onFailure: (previewFailure): Return => ({ + model: { + ...model, + previewShown: generation, + isPreviewLoading: generation !== model.previewGeneration, + previewFailure, + }, + }), + }), + ClickedSubmit: ({ mode }) => submit(model, mode), + CompletedCreate: ({ mode, result }) => + Result.match(result, { + onSuccess: (page): Return => ({ + model: { ...model, submitting: null }, + outMessage: PageOutMessage.RequestedNavigation({ + url: statusPageHref(page.id, mode === "publish" ? "live" : undefined), + isReplace: true, + ...(mode === "draft" + ? { + toast: { + type: "success", + title: "Saved as a draft", + description: page.name, + }, + } + : undefined), + }), + }), + onFailure: (failure): Return => + failure.code === SLUG_TAKEN + ? { + model: { ...model, submitting: null, takenSlug: model.slug }, + commands: [FocusBuilderField({ id: SLUG_ID })], + } + : { model: { ...model, submitting: null, createFailure: failure } }, + }), + }) diff --git a/apps/web/src/page/status-pages/builder/view.ts b/apps/web/src/page/status-pages/builder/view.ts new file mode 100644 index 0000000000..45cf3f86ab --- /dev/null +++ b/apps/web/src/page/status-pages/builder/view.ts @@ -0,0 +1,338 @@ +import { StatusPageComponentId, StatusPageId } from "@maple/domain" +import { DEFAULT_STATUS_PAGE_THEME, StatusPageSlug } from "@maple/domain/status-page" +import { previewSnapshot } from "@maple/status-page" +import { Schema } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "../../../data/load-failure" +import { alert, alertDescription, alertTitle } from "../../../ui/alert" +import { button, linkButton } from "../../../ui/button" +import { errorState } from "../../../ui/error-state" +import { icon } from "../../../ui/icon" +import { segmentedSelect } from "../../../ui/segmented-select" +import { skeleton } from "../../../ui/skeleton" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { resourceNotFound } from "../../kit/resource-not-found" +import { settingsSection } from "../../settings/kit/section" +import { publicUrl, statusPagesHref } from "../shared/commands" +import { textField } from "../shared/fields" +import { APPEARANCES, presetCards } from "../shared/look" +import { checkedRows } from "../shared/picker" +import { pickerView } from "../shared/picker-view" +import { daysOf, previewDocument, previewFrame, previewTheme } from "../shared/preview" +import { Message } from "./message" +import type { Model } from "./model" +import { canSubmit, monitorsOf, NAME_ID, nameIssue, PICKER_ID, SLUG_ID, slugError } from "./update" + +// `/status-pages/new`: the form on the left, the page as visitors get it on the right, and the +// publish bar under both. Below 1000px the preview stacks under the form. + +type H = HtmlBuilder + +const isSlug = Schema.is(StatusPageSlug) +const DRAFT_PAGE_ID = Schema.decodeSync(StatusPageId)("00000000-0000-4000-8000-000000000000") +const draftComponentId = (index: number) => + Schema.decodeSync(StatusPageComponentId)(`00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`) + +const pageSection = (h: H, model: Model): Html => + settingsSection(h, { id: "status-page-builder-page", title: "Page" }, [ + h.div( + [h.Class("flex flex-col gap-4")], + [ + textField(h, { + id: NAME_ID, + label: "Name", + value: model.name, + placeholder: "Acme Status", + error: nameIssue(model), + onInput: (value) => Message.ChangedName({ value }), + onBlur: Message.BlurredName(), + }), + textField(h, { + id: SLUG_ID, + label: "Address", + value: model.slug, + placeholder: "acme-status", + isMono: true, + hint: model.slug === "" ? "Follows the name until you type one." : publicUrl(model.slug), + error: slugError(model), + onInput: (value) => Message.ChangedSlug({ value }), + }), + ], + ), + ]) + +const componentsBody = (h: H, model: Model): Html => { + const data = model.monitors.data + if (AsyncData.isFailure(data)) + return errorState(h, { + failure: data.error, + title: "Couldn't load your monitors", + onRetry: Message.ClickedRetry(), + className: "rounded-md border py-8", + }) + if (!model.isSeeded) + return h.div( + [h.Class("flex flex-col gap-2"), h.AriaBusy(true)], + [skeleton(h, "h-12 w-full"), skeleton(h, "h-12 w-full"), skeleton(h, "h-12 w-full")], + ) + return pickerView(h, { + picker: model.picker, + monitors: monitorsOf(model), + toMessage: (message) => Message.GotPickerMessage({ message }), + idPrefix: PICKER_ID, + isDisabled: model.submitting !== null, + }) +} + +const componentsSection = (h: H, model: Model): Html => { + const count = checkedRows(model.picker).length + return settingsSection( + h, + { + id: "status-page-builder-components", + title: "What's on it", + description: [ + count === 0 + ? "Each component shows its status and uptime. Monitored ones follow their monitor." + : `${count} ${count === 1 ? "component" : "components"}. Monitored ones follow their monitor; rename or group them as visitors should read them.`, + ], + }, + [componentsBody(h, model)], + ) +} + +const lookSection = (h: H, model: Model): Html => + settingsSection( + h, + { + id: "status-page-builder-look", + title: "Look", + description: ["Logo, colors, fonts and links later, in Settings."], + }, + [ + h.div( + [h.Class("flex flex-col gap-4")], + [ + presetCards(h, { + selected: model.preset, + isDisabled: model.submitting !== null, + onPick: (preset) => Message.PickedPreset({ preset }), + }), + h.div( + [h.Class("flex flex-wrap items-center gap-3")], + [ + h.span([h.Class("text-xs text-muted-foreground")], ["Mode"]), + segmentedSelect(h, { + size: "sm", + ariaLabel: "Mode", + value: model.appearance, + onChange: (appearance) => Message.PickedAppearance({ appearance }), + options: APPEARANCES, + }), + h.span( + [h.Class("text-xs text-muted-foreground")], + [ + model.appearance === "auto" + ? "Follows each visitor's setting." + : `Every visitor sees it ${model.appearance}.`, + ], + ), + ], + ), + ], + ), + ], + ) + +/** The draft as the public renderer draws it, with the monitors' history from the last preview. */ +const draftDocument = (model: Model): string => { + const nowMs = model.nowMs ?? 0 + const monitors = monitorsOf(model) + const snapshot = previewSnapshot({ + pageId: DRAFT_PAGE_ID, + page: { + name: model.name.trim() === "" ? "Status page" : model.name.trim(), + slug: isSlug(model.slug) ? model.slug : "preview", + description: null, + websiteUrl: null, + logoUrl: null, + privacyUrl: null, + termsUrl: null, + showPoweredBy: true, + theme: previewTheme( + { ...DEFAULT_STATUS_PAGE_THEME, preset: model.preset, appearance: model.appearance }, + model.previewScheme, + ), + }, + components: checkedRows(model.picker).map((row, index) => ({ + id: draftComponentId(index), + name: row.name.trim() === "" ? "Untitled component" : row.name.trim(), + description: null, + group: row.group.trim() === "" ? null : row.group.trim(), + monitorStatus: monitors.find((monitor) => monitor.id === row.monitorId)?.state.status ?? null, + manualStatus: row.monitorId === null ? row.manualStatus : null, + showUptime: true, + monitored: row.monitorId !== null, + days: daysOf(model.previewHistory, row.monitorId), + })), + incidents: [], + nowMs, + }) + return previewDocument({ snapshot, nowMs, isSelectable: false, selectedIds: [] }) +} + +const previewPanel = (h: H, model: Model): Html => + h.section( + [ + h.AriaLabelledBy("status-page-builder-preview"), + h.Class("flex min-w-0 flex-col gap-3 min-[1000px]:sticky min-[1000px]:top-0"), + ], + [ + h.div( + [h.Class("flex min-h-7 items-center justify-between gap-3")], + [ + h.div( + [h.Class("flex items-center gap-2")], + [ + h.h2( + [h.Id("status-page-builder-preview"), h.Class("text-sm font-medium")], + ["Preview"], + ), + h.span( + [h.Class("font-mono text-xs text-muted-foreground"), h.AriaLive("polite")], + [model.isPreviewLoading ? "Updating" : ""], + ), + ], + ), + ...(model.appearance === "auto" + ? [ + segmentedSelect(h, { + size: "xs", + ariaLabel: "Preview mode", + value: model.previewScheme, + onChange: (scheme) => Message.PickedPreviewScheme({ scheme }), + options: [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + ], + }), + ] + : []), + ], + ), + previewFrame(h, { + title: "Status page preview", + document: draftDocument(model), + className: "h-[560px] min-[1000px]:h-[calc(100vh-15rem)] min-[1000px]:min-h-[520px]", + }), + model.previewFailure === null + ? h.p( + [h.Class("text-xs text-muted-foreground")], + ["Exactly what visitors get, with your monitors' real uptime."], + ) + : h.p( + [h.Class("text-xs text-muted-foreground")], + [`Uptime history unavailable: ${failureMessage(model.previewFailure)}`], + ), + ], + ) + +const footerBar = (h: H, model: Model): Html => { + const isReady = canSubmit(model) + return h.div( + [ + h.Class( + "sticky bottom-4 z-10 flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-popover py-2 pr-2 pl-4 shadow-lg", + ), + ], + [ + h.p([h.Class("text-xs text-muted-foreground")], ["Nothing is public until you publish."]), + h.div( + [h.Class("flex items-center gap-2")], + [ + button( + h, + { + size: "sm", + variant: "outline", + isLoading: model.submitting === "draft", + isDisabled: !isReady, + attributes: [h.OnClick(Message.ClickedSubmit({ mode: "draft" }))], + }, + ["Save as draft"], + ), + button( + h, + { + size: "sm", + isLoading: model.submitting === "publish", + isDisabled: !isReady, + attributes: [h.OnClick(Message.ClickedSubmit({ mode: "publish" }))], + }, + ["Publish page"], + ), + ], + ), + ], + ) +} + +const builder = (h: H, model: Model): Html => + h.div( + [h.Class("flex flex-col gap-6")], + [ + h.div( + [h.Class("grid items-start gap-8 min-[1000px]:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]")], + [ + h.div( + [h.Class("flex min-w-0 flex-col gap-8")], + [ + pageSection(h, model), + componentsSection(h, model), + lookSection(h, model), + ...(model.createFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't create the page"]), + alertDescription(h, [failureMessage(model.createFailure)]), + ]), + ]), + ], + ), + previewPanel(h, model), + ], + ), + footerBar(h, model), + ], + ) + +const membersOnly = (h: H): Html => + resourceNotFound(h, { + title: "Only org admins can create status pages", + description: "Ask an admin to create one. You can read the existing pages.", + back: { href: statusPagesHref(), label: "Back to status pages" }, + }) + +export const view = Submodel.defineView((model, _inputs, h) => + dashboardPage( + h, + { + title: "New status page", + actions: linkButton(h, { size: "sm", variant: "ghost", href: statusPagesHref() }, ["Cancel"]), + }, + [ + !model.isSessionLoaded + ? h.div( + [h.Class("flex flex-col gap-4"), h.AriaBusy(true)], + [skeleton(h, "h-9 w-1/3"), skeleton(h, "h-64 w-full")], + ) + : model.isAdmin + ? builder(h, model) + : membersOnly(h), + ], + ), +) diff --git a/apps/web/src/page/status-pages/shared/look.ts b/apps/web/src/page/status-pages/shared/look.ts new file mode 100644 index 0000000000..e7507459c9 --- /dev/null +++ b/apps/web/src/page/status-pages/shared/look.ts @@ -0,0 +1,88 @@ +import { + DEFAULT_STATUS_PAGE_THEME, + resolveStatusPageTheme, + type StatusPageThemeAppearance, + type StatusPageThemePreset, +} from "@maple/domain/status-page" +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" + +// The theme presets as small cards, shared by the builder's Look and the Settings appearance. + +export const PRESETS: ReadonlyArray<{ value: StatusPageThemePreset; label: string }> = [ + { value: "default", label: "Default" }, + { value: "mono", label: "Mono" }, + { value: "midnight", label: "Midnight" }, + { value: "paper", label: "Paper" }, +] + +export const APPEARANCES: ReadonlyArray<{ value: StatusPageThemeAppearance; label: string }> = [ + { value: "auto", label: "Auto" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, +] + +/** One preset: its light and dark page colours side by side. */ +const presetCard = ( + h: HtmlBuilder, + preset: (typeof PRESETS)[number], + options: Readonly<{ + selected: StatusPageThemePreset + isDisabled: boolean + onPick: (preset: StatusPageThemePreset) => M + }>, +): Html => { + const tokens = resolveStatusPageTheme({ ...DEFAULT_STATUS_PAGE_THEME, preset: preset.value }) + const isSelected = options.selected === preset.value + const half = (scheme: typeof tokens.light) => + h.span( + [ + h.Class("flex flex-1 flex-col justify-center gap-1 px-2"), + h.Style({ backgroundColor: scheme.bg }), + ], + [ + h.span([h.Class("h-1 w-8 rounded-full"), h.Style({ backgroundColor: scheme.fg })], []), + h.span( + [h.Class("flex gap-0.5")], + [scheme.ok, scheme.ok, scheme.degraded, scheme.ok].map((color) => + h.span([h.Class("h-2 w-1.5 rounded-[1px]"), h.Style({ backgroundColor: color })], []), + ), + ), + ], + ) + return h.button( + [ + h.Type("button"), + h.Key(preset.value), + h.AriaPressed(isSelected ? "true" : "false"), + h.Disabled(options.isDisabled), + h.OnClick(options.onPick(preset.value)), + h.Class( + cn( + "flex flex-col gap-1.5 rounded-lg border p-1.5 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-60", + isSelected ? "border-primary/70 bg-primary/5" : "hover:bg-muted/50", + ), + ), + ], + [ + h.span( + [h.AriaHidden(true), h.Class("flex h-10 overflow-hidden rounded-md border")], + [half(tokens.light), half(tokens.dark)], + ), + h.span([h.Class("px-0.5 font-medium text-foreground")], [preset.label]), + ], + ) +} + +export const presetCards = ( + h: HtmlBuilder, + options: Readonly<{ + selected: StatusPageThemePreset + isDisabled: boolean + onPick: (preset: StatusPageThemePreset) => M + }>, +): Html => + h.div( + [h.Role("group"), h.AriaLabel("Preset"), h.Class("grid grid-cols-2 gap-2 sm:grid-cols-4")], + PRESETS.map((preset) => presetCard(h, preset, options)), + ) diff --git a/apps/web/src/page/status-pages/shared/picker-view.ts b/apps/web/src/page/status-pages/shared/picker-view.ts new file mode 100644 index 0000000000..47776728b4 --- /dev/null +++ b/apps/web/src/page/status-pages/shared/picker-view.ts @@ -0,0 +1,406 @@ +import type { V2UptimeMonitor } from "@maple/domain/http/v2" +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "../../../ui/button" +import * as Checkbox from "../../../ui/checkbox" +import { icon } from "../../../ui/icon" +import { iconButton } from "../../../ui/icon-button" +import { input } from "../../../ui/input" +import { nativeOption, nativeSelect } from "../../../ui/native-select" +import { COMPONENT_STATUS_LABEL, COMPONENT_STATUS_OPTIONS } from "./derive" +import { checkedRows, monitorTarget, type Picker, PickerMessage, type PickerRow, rowIssue } from "./picker" + +// The checklist view: checked rows rename, group and move in place; the URL field and the +// add-by-hand row sit under it. + +export type PickerViewOptions = Readonly<{ + picker: Picker + monitors: ReadonlyArray + toMessage: (message: PickerMessage) => M + /** Prefix of every control id, unique per page. */ + idPrefix: string + isDisabled?: boolean + /** Ordering and grouping apply to new pages only; the editor adds rows at the end. */ + isOrderable?: boolean +}> + +const rowId = (prefix: string, row: PickerRow, part: string) => + `${prefix}-${part}-${row.key.replace(":", "-")}` + +const textInput = ( + h: HtmlBuilder, + options: Readonly<{ + id: string + label: string + value: string + placeholder: string + isDisabled: boolean + error?: string | null + className?: string + onInput: (value: string) => M + }>, +): Html => + input(h, { + id: options.id, + size: "sm", + isDisabled: options.isDisabled, + className: options.className, + attributes: [ + h.AriaLabel(options.label), + h.Value(options.value), + h.Placeholder(options.placeholder), + h.OnInput(options.onInput), + ...((options.error ?? null) === null + ? [] + : [h.AriaInvalid(true), h.AriaDescribedBy(`${options.id}-error`)]), + ], + }) + +const moveButtons = ( + h: HtmlBuilder, + options: PickerViewOptions, + row: PickerRow, + position: Readonly<{ isFirst: boolean; isLast: boolean }>, +): Html => { + const name = row.name.trim() === "" ? "component" : row.name.trim() + return h.div( + [h.Class("flex shrink-0 items-center")], + [ + iconButton( + h, + { + label: `Move ${name} up`, + size: "icon-xs", + isDisabled: options.isDisabled === true || position.isFirst, + attributes: [ + h.OnClick( + options.toMessage(PickerMessage.ClickedMoveRow({ key: row.key, offset: -1 })), + ), + ], + }, + [icon(h, "ChevronUpIcon")], + ), + iconButton( + h, + { + label: `Move ${name} down`, + size: "icon-xs", + isDisabled: options.isDisabled === true || position.isLast, + attributes: [ + h.OnClick( + options.toMessage(PickerMessage.ClickedMoveRow({ key: row.key, offset: 1 })), + ), + ], + }, + [icon(h, "ChevronDownIcon")], + ), + ], + ) +} + +const editFields = ( + h: HtmlBuilder, + options: PickerViewOptions, + row: PickerRow, + label: string, +): Html => { + const error = rowIssue(row) + const nameId = rowId(options.idPrefix, row, "name") + const isDisabled = options.isDisabled === true + return h.div( + [h.Class("flex min-w-0 flex-1 flex-col gap-1")], + [ + h.div( + [h.Class("grid gap-2 sm:grid-cols-[minmax(0,1fr)_9rem]")], + [ + textInput(h, { + id: nameId, + label: `Shown as, ${label}`, + value: row.name, + placeholder: row.monitorId === null ? "Component name" : label, + isDisabled, + error, + onInput: (value) => + options.toMessage(PickerMessage.ChangedRowName({ key: row.key, value })), + }), + textInput(h, { + id: rowId(options.idPrefix, row, "group"), + label: `Group of ${label}`, + value: row.group, + placeholder: "Group", + isDisabled, + onInput: (value) => + options.toMessage(PickerMessage.ChangedRowGroup({ key: row.key, value })), + }), + ], + ), + ...(error === null + ? [] + : [h.p([h.Id(`${nameId}-error`), h.Class("text-xs text-destructive")], [error])]), + ], + ) +} + +const statusSelect = ( + h: HtmlBuilder, + options: PickerViewOptions, + row: PickerRow, + label: string, +): Html => + nativeSelect( + h, + { + defaultValue: row.manualStatus, + size: "sm", + className: "w-36 shrink-0", + isDisabled: options.isDisabled, + attributes: [ + h.Key(`${row.key}-${row.manualStatus}`), + h.AriaLabel(`Status of ${label}`), + h.OnChange((value) => + options.toMessage(PickerMessage.PickedRowStatus({ key: row.key, value })), + ), + ], + }, + COMPONENT_STATUS_OPTIONS.map((status) => nativeOption(status, COMPONENT_STATUS_LABEL[status])), + ) + +const monitorRowView = ( + h: HtmlBuilder, + options: PickerViewOptions, + row: PickerRow, + monitor: V2UptimeMonitor | undefined, + position: Readonly<{ isFirst: boolean; isLast: boolean }>, +): Html => { + const label = monitor?.name ?? row.name + const inputId = rowId(options.idPrefix, row, "check") + const target = monitor === undefined ? "" : monitorTarget(monitor) + return h.li( + [ + h.Key(row.key), + h.Class(cn("flex items-start gap-3 px-3 py-2.5", row.isChecked ? "" : "text-muted-foreground")), + ], + [ + h.label( + [h.Id(Checkbox.labelId(inputId)), h.Class("mt-1.5 flex shrink-0 items-center")], + [ + ...Checkbox.view(h, { + model: { isChecked: row.isChecked }, + toParentMessage: () => options.toMessage(PickerMessage.ToggledRow({ key: row.key })), + id: `${inputId}-root`, + inputId, + isDisabled: options.isDisabled, + ariaLabel: `Show ${label} on the page`, + }), + ], + ), + row.isChecked + ? h.div( + [h.Class("flex min-w-0 flex-1 flex-col gap-1")], + [ + editFields(h, options, row, label), + h.p([h.Class("truncate font-mono text-xs text-muted-foreground")], [target]), + ], + ) + : h.div( + [h.Class("flex min-w-0 flex-1 flex-col gap-0.5 pt-1")], + [ + h.span([h.Class("truncate text-sm")], [label]), + h.span([h.Class("truncate font-mono text-xs")], [target]), + ], + ), + ...(row.isChecked && options.isOrderable !== false + ? [moveButtons(h, options, row, position)] + : []), + ], + ) +} + +const manualRowView = ( + h: HtmlBuilder, + options: PickerViewOptions, + row: PickerRow, + position: Readonly<{ isFirst: boolean; isLast: boolean }>, +): Html => { + const label = row.name.trim() === "" ? "the new component" : row.name.trim() + return h.li( + [h.Key(row.key), h.Class("flex items-start gap-3 px-3 py-2.5")], + [ + h.span( + [h.AriaHidden(true), h.Class("mt-2 flex size-4 shrink-0 items-center justify-center")], + [icon(h, "PencilIcon", { size: 12, className: "text-muted-foreground" })], + ), + h.div( + [h.Class("flex min-w-0 flex-1 flex-col gap-1")], + [ + editFields(h, options, row, label), + h.div( + [h.Class("flex items-center gap-2")], + [ + statusSelect(h, options, row, label), + h.span([h.Class("text-xs text-muted-foreground")], ["Updated by hand"]), + ], + ), + ], + ), + h.div( + [h.Class("flex shrink-0 items-center")], + [ + ...(options.isOrderable === false ? [] : [moveButtons(h, options, row, position)]), + iconButton( + h, + { + label: `Remove ${label}`, + size: "icon-xs", + isDisabled: options.isDisabled, + attributes: [ + h.OnClick( + options.toMessage(PickerMessage.ClickedRemoveRow({ key: row.key })), + ), + ], + }, + [icon(h, "XmarkIcon")], + ), + ], + ), + ], + ) +} + +const urlField = (h: HtmlBuilder, options: PickerViewOptions): Html => { + const picker = options.picker + const id = `${options.idPrefix}-url` + if (!picker.isUrlOpen) + return button( + h, + { + size: "sm", + variant: "ghost", + isDisabled: options.isDisabled, + attributes: [h.OnClick(options.toMessage(PickerMessage.ClickedOpenUrl()))], + }, + [icon(h, "PlusIcon"), "Monitor a URL"], + ) + return h.form( + [ + h.Class("flex flex-col gap-1.5"), + h.OnSubmit(options.toMessage(PickerMessage.SubmittedUrl())), + h.AriaLabel("Monitor a URL"), + ], + [ + h.div( + [h.Class("flex items-center gap-2")], + [ + input(h, { + id, + size: "sm", + type: "url", + className: "flex-1", + controlClassName: "font-mono", + isDisabled: options.isDisabled === true || picker.isCreatingMonitor, + attributes: [ + h.AriaLabel("URL to monitor"), + h.Value(picker.url), + h.Placeholder("https://api.acme.com"), + h.OnInput((value) => options.toMessage(PickerMessage.ChangedUrl({ value }))), + ...(picker.urlIssue === null + ? [] + : [h.AriaInvalid(true), h.AriaDescribedBy(`${id}-error`)]), + ], + }), + button( + h, + { + size: "sm", + variant: "outline", + type: "submit", + isLoading: picker.isCreatingMonitor, + isDisabled: options.isDisabled, + }, + ["Monitor it"], + ), + ...(options.monitors.length === 0 + ? [] + : [ + button( + h, + { + size: "sm", + variant: "ghost", + isDisabled: picker.isCreatingMonitor, + attributes: [ + h.OnClick(options.toMessage(PickerMessage.ClickedCancelUrl())), + ], + }, + ["Cancel"], + ), + ]), + ], + ), + picker.urlIssue === null + ? h.p( + [h.Class("text-xs text-muted-foreground")], + ["Maple checks it every minute and the page shows its uptime."], + ) + : h.p([h.Id(`${id}-error`), h.Class("text-xs text-destructive")], [picker.urlIssue]), + ], + ) +} + +export const pickerView = (h: HtmlBuilder, options: PickerViewOptions): Html => { + const picker = options.picker + const checked = checkedRows(picker) + const positionOf = (row: PickerRow) => ({ + isFirst: checked[0]?.key === row.key, + isLast: checked.at(-1)?.key === row.key, + }) + const isEmpty = picker.rows.length === 0 + return h.div( + [h.Class("flex flex-col gap-3")], + [ + ...(isEmpty + ? [ + h.p( + [h.Class("text-sm text-muted-foreground")], + ["Paste a URL to monitor, or add a component you'll update by hand."], + ), + ] + : [ + h.ul( + [h.AriaLabel("Components"), h.Class("divide-y rounded-md border")], + picker.rows.map((row) => + row.monitorId === null + ? manualRowView(h, options, row, positionOf(row)) + : monitorRowView( + h, + options, + row, + options.monitors.find((monitor) => monitor.id === row.monitorId), + positionOf(row), + ), + ), + ), + ]), + h.div( + [h.Class(cn("flex flex-col gap-2", isEmpty ? "" : "sm:flex-row sm:items-start sm:gap-3"))], + [ + urlField(h, options), + button( + h, + { + size: "sm", + variant: "ghost", + isDisabled: options.isDisabled, + className: "self-start", + attributes: [h.OnClick(options.toMessage(PickerMessage.ClickedAddManual()))], + }, + [icon(h, "PlusIcon"), "Add a component without a monitor"], + ), + ], + ), + ], + ) +} + +/** The id of a row's name input, for focusing a new one. */ +export const rowNameId = (prefix: string, key: string) => `${prefix}-name-${key.replace(":", "-")}` diff --git a/apps/web/src/page/status-pages/shared/picker.ts b/apps/web/src/page/status-pages/shared/picker.ts new file mode 100644 index 0000000000..4100baf1cc --- /dev/null +++ b/apps/web/src/page/status-pages/shared/picker.ts @@ -0,0 +1,232 @@ +import { StatusPageComponentStatus, UptimeMonitorId } from "@maple/domain" +import type { StatusPageDraftComponent, V2UptimeMonitor } from "@maple/domain/http/v2" +import { STATUS_PAGE_MAX_URL_LENGTH } from "@maple/domain/status-page" +import { Option, Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { bareUrl } from "../../alerts/shared/uptime-derive" +import { formIssues, nameFromUrl, wirePayload } from "../../alerts/uptime-form/derive" +import { emptyForm } from "../../alerts/uptime-form/form" + +// "What's on it": the org's monitors as a checklist, components without a monitor, and a URL +// that becomes a new HTTP monitor. The builder and the editor's Add component share it. + +/** Pages this small start with every monitor on them; past it the user picks. */ +export const PRECHECK_MAX_MONITORS = 8 + +export const PickerRow = Schema.Struct({ + key: Schema.String, + /** `null` for a component without a monitor, whose status is set by hand. */ + monitorId: Schema.NullOr(UptimeMonitorId), + isChecked: Schema.Boolean, + /** "Shown as": the name on the page, the monitor's own until renamed. */ + name: Schema.String, + group: Schema.String, + manualStatus: StatusPageComponentStatus, +}) +export type PickerRow = typeof PickerRow.Type + +export const Picker = Schema.Struct({ + rows: Schema.Array(PickerRow), + nextKey: Schema.Number, + isUrlOpen: Schema.Boolean, + url: Schema.String, + urlIssue: Schema.NullOr(Schema.String), + isCreatingMonitor: Schema.Boolean, +}) +export type Picker = typeof Picker.Type + +export const emptyPicker: Picker = { + rows: [], + nextKey: 1, + isUrlOpen: false, + url: "", + urlIssue: null, + isCreatingMonitor: false, +} + +export const monitorKey = (id: UptimeMonitorId) => `m:${id}` + +const monitorRow = (monitor: V2UptimeMonitor, isChecked: boolean): PickerRow => ({ + key: monitorKey(monitor.id), + monitorId: monitor.id, + isChecked, + name: monitor.name, + group: "", + manualStatus: "operational", +}) + +/** One row per monitor; a small org starts with all but heartbeats checked. */ +export const seedPicker = (monitors: ReadonlyArray, precheck: boolean): Picker => ({ + ...emptyPicker, + isUrlOpen: monitors.length === 0, + rows: monitors.map((monitor) => + monitorRow( + monitor, + precheck && monitors.length <= PRECHECK_MAX_MONITORS && monitor.kind !== "heartbeat", + ), + ), +}) + +/** What a monitor checks: its URL, host and port, record, or "heartbeat". */ +export const monitorTarget = (monitor: V2UptimeMonitor): string => { + const config = monitor.config + switch (config.type) { + case "http": + return bareUrl(config.url) + case "tcp": + return `${config.host}:${config.port}` + case "dns": + return `${config.record_type} ${config.hostname}` + case "heartbeat": + return "heartbeat" + } +} + +export const PickerMessage = defineMessageUnion({ + ToggledRow: { key: Schema.String }, + ChangedRowName: { key: Schema.String, value: Schema.String }, + ChangedRowGroup: { key: Schema.String, value: Schema.String }, + PickedRowStatus: { key: Schema.String, value: Schema.String }, + ClickedMoveRow: { key: Schema.String, offset: Schema.Number }, + ClickedRemoveRow: { key: Schema.String }, + ClickedAddManual: {}, + ClickedOpenUrl: {}, + ChangedUrl: { value: Schema.String }, + SubmittedUrl: {}, + ClickedCancelUrl: {}, +}) +export type PickerMessage = typeof PickerMessage.Type + +const decodeStatus = Schema.decodeUnknownOption(StatusPageComponentStatus) + +const mapRow = (picker: Picker, key: string, change: (row: PickerRow) => PickerRow): Picker => ({ + ...picker, + rows: picker.rows.map((row) => (row.key === key ? change(row) : row)), +}) + +/** Swaps the row with the next checked row in `offset`'s direction; unchecked rows stay put. */ +export const moveRow = (picker: Picker, key: string, offset: number): Picker => { + const from = picker.rows.findIndex((row) => row.key === key) + const checked = picker.rows.flatMap((row, index) => (row.isChecked ? [index] : [])) + const at = checked.indexOf(from) + const to = checked[at + (offset < 0 ? -1 : 1)] + const row = picker.rows[from] + const other = to === undefined ? undefined : picker.rows[to] + if (at < 0 || to === undefined || row === undefined || other === undefined) return picker + return { + ...picker, + rows: picker.rows.map((entry, index) => (index === from ? other : index === to ? row : entry)), + } +} + +/** A `https://` URL for what was pasted: a bare host gets the scheme. */ +export const normalizeUrl = (raw: string): string => { + const trimmed = raw.trim() + return trimmed === "" || /^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed) ? trimmed : `https://${trimmed}` +} + +/** The uptime form's own check of an HTTP target, so the server's refusals read the same here. */ +export const urlIssueOf = (url: string): string | null => + url === "" + ? "Paste the URL to check." + : url.length > STATUS_PAGE_MAX_URL_LENGTH + ? `Keep it under ${STATUS_PAGE_MAX_URL_LENGTH} characters.` + : (formIssues(emptyForm({ url, name: nameFromUrl(url) }))[0] ?? null) + +/** The create payload of a new HTTP check with the uptime form's defaults. */ +export const monitorPayloadFor = (url: string): Readonly> => + wirePayload(emptyForm({ url, name: nameFromUrl(url) }), false) + +export type PickerStep = Readonly<{ + picker: Picker + /** A URL to create a monitor for, once it passes the checks. */ + createMonitor: Option.Option + /** A row's name input to focus (a new component without a monitor). */ + focusKey: Option.Option +}> + +const step = (picker: Picker): PickerStep => ({ + picker, + createMonitor: Option.none(), + focusKey: Option.none(), +}) + +export const updatePicker = (picker: Picker, message: PickerMessage): PickerStep => + PickerMessage.match(message, { + ToggledRow: ({ key }) => step(mapRow(picker, key, (row) => ({ ...row, isChecked: !row.isChecked }))), + ChangedRowName: ({ key, value }) => step(mapRow(picker, key, (row) => ({ ...row, name: value }))), + ChangedRowGroup: ({ key, value }) => step(mapRow(picker, key, (row) => ({ ...row, group: value }))), + PickedRowStatus: ({ key, value }) => + Option.match(decodeStatus(value), { + onNone: () => step(picker), + onSome: (manualStatus) => step(mapRow(picker, key, (row) => ({ ...row, manualStatus }))), + }), + ClickedMoveRow: ({ key, offset }) => step(moveRow(picker, key, offset)), + ClickedRemoveRow: ({ key }) => + step({ ...picker, rows: picker.rows.filter((row) => row.key !== key || row.monitorId !== null) }), + ClickedAddManual: () => { + const key = `c:${picker.nextKey}` + const row: PickerRow = { + key, + monitorId: null, + isChecked: true, + name: "", + group: "", + manualStatus: "operational", + } + return { + ...step({ ...picker, rows: [...picker.rows, row], nextKey: picker.nextKey + 1 }), + focusKey: Option.some(key), + } + }, + ClickedOpenUrl: () => step({ ...picker, isUrlOpen: true, urlIssue: null }), + ChangedUrl: ({ value }) => step({ ...picker, url: value, urlIssue: null }), + ClickedCancelUrl: () => step({ ...picker, isUrlOpen: false, url: "", urlIssue: null }), + SubmittedUrl: () => { + if (picker.isCreatingMonitor) return step(picker) + const url = normalizeUrl(picker.url) + const issue = urlIssueOf(url) + return issue !== null + ? step({ ...picker, urlIssue: issue }) + : { + ...step({ ...picker, url, isCreatingMonitor: true, urlIssue: null }), + createMonitor: Option.some(url), + } + }, + }) + +/** The new monitor joins the list checked, at the end, and the field clears for the next. */ +export const monitorCreated = (picker: Picker, monitor: V2UptimeMonitor): Picker => ({ + ...picker, + isCreatingMonitor: false, + url: "", + urlIssue: null, + rows: [...picker.rows.filter((row) => row.key !== monitorKey(monitor.id)), monitorRow(monitor, true)], +}) + +export const monitorFailed = (picker: Picker, message: string): Picker => ({ + ...picker, + isCreatingMonitor: false, + urlIssue: message, +}) + +/** Rows the page lists, in order. */ +export const checkedRows = (picker: Picker): ReadonlyArray => + picker.rows.filter((row) => row.isChecked) + +export const rowIssue = (row: PickerRow): string | null => + row.isChecked && row.name.trim() === "" ? "Add a name." : null + +export const hasRowIssues = (picker: Picker): boolean => picker.rows.some((row) => rowIssue(row) !== null) + +const orNull = (value: string): string | null => (value.trim() === "" ? null : value.trim()) + +/** The page's components as the create and the preview take them, in list order. */ +export const draftComponents = (picker: Picker): ReadonlyArray => + checkedRows(picker).map((row) => ({ + name: row.name.trim(), + group: orNull(row.group), + monitor_id: row.monitorId, + manual_status: row.monitorId === null ? row.manualStatus : null, + show_uptime: true, + })) diff --git a/apps/web/src/page/status-pages/shared/preview.ts b/apps/web/src/page/status-pages/shared/preview.ts new file mode 100644 index 0000000000..439486ba4e --- /dev/null +++ b/apps/web/src/page/status-pages/shared/preview.ts @@ -0,0 +1,182 @@ +import { UptimeMonitorId } from "@maple/domain" +import type { StatusPagePreviewParams, V2StatusPageIncident } from "@maple/domain/http/v2" +import { + STATUS_PAGE_INCIDENT_HISTORY_DAYS, + StatusPageDayBar, + type StatusPageSnapshot, + type StatusPageSnapshotIncident, + type StatusPageTheme, +} from "@maple/domain/status-page" +import { renderPage } from "@maple/status-page" +import { cn } from "@maple/ui/lib/utils" +import { Option, Schema, Stream } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { isoMs } from "../../alerts/shared/time" + +// The live preview: the public renderer's page in a sandboxed frame, with the monitors' real +// history, and in the editor a click on a component or incident posted back to select it. + +/** The scheme the preview shows an Auto page in; the page itself follows each visitor. */ +export const PreviewScheme = Schema.Literals(["light", "dark"]) +export type PreviewScheme = typeof PreviewScheme.Type + +/** Each monitor's daily bars as the preview endpoint last read them. */ +export const PreviewHistory = Schema.Array( + Schema.Struct({ monitorId: UptimeMonitorId, days: Schema.Array(StatusPageDayBar) }), +) +export type PreviewHistory = typeof PreviewHistory.Type + +/** The preview answers one component per one sent, in order: pair them back up by monitor. */ +export const historyFrom = (sent: StatusPagePreviewParams, snapshot: StatusPageSnapshot): PreviewHistory => + sent.components.flatMap((component, index) => { + const days = snapshot.components[index]?.days ?? [] + return component.monitor_id === null || component.monitor_id === undefined || days.length === 0 + ? [] + : [{ monitorId: component.monitor_id, days }] + }) + +export const daysOf = (history: PreviewHistory, monitorId: UptimeMonitorId | null) => + monitorId === null ? undefined : history.find((entry) => entry.monitorId === monitorId)?.days + +/** Auto pages preview in the picked scheme, with the overrides Auto ignores left out. */ +export const previewTheme = (theme: StatusPageTheme, scheme: PreviewScheme): StatusPageTheme => + theme.appearance === "auto" + ? { ...theme, appearance: scheme, background: null, text: null, card: null, border: null } + : theme + +const DAY_MS = 86_400_000 + +/** Whether visitors see the incident now: published, and open or recently closed. */ +export const isListedIncident = (incident: V2StatusPageIncident, nowMs: number): boolean => + incident.published && + (incident.resolved_at === null || + isoMs(incident.resolved_at) >= nowMs - STATUS_PAGE_INCIDENT_HISTORY_DAYS * DAY_MS) + +const msOrNull = (value: string | null): number | null => (value === null ? null : isoMs(value)) + +export const toSnapshotIncident = (incident: V2StatusPageIncident): StatusPageSnapshotIncident => ({ + id: incident.id, + kind: incident.kind, + title: incident.title, + impact: incident.impact, + status: incident.status, + startedAt: isoMs(incident.started_at), + resolvedAt: msOrNull(incident.resolved_at), + scheduledStart: msOrNull(incident.scheduled_start), + scheduledEnd: msOrNull(incident.scheduled_end), + componentIds: incident.component_ids, + openedByOverlayEntryId: null, + updates: incident.updates + .map((update) => ({ + id: update.id, + status: update.status, + body: update.body, + at: isoMs(update.created_at), + source: update.source, + overlayEntryId: null, + })) + .sort((a, b) => b.at - a.at), +}) + +// SELECTION + +/** What a click in the preview selects. */ +export const PreviewSelection = Schema.Struct({ + kind: Schema.Literals(["component", "incident"]), + id: Schema.String, +}) +export type PreviewSelection = typeof PreviewSelection.Type + +const SelectMessage = Schema.Struct({ + type: Schema.Literal("select"), + kind: PreviewSelection.fields.kind, + id: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{1,64}$/u)), +}) +const decodeSelect = Schema.decodeUnknownOption(SelectMessage) + +/** A preview message's selection; anything not exactly `{ type: "select", kind, id }` is none. */ +export const selectionOf = (data: unknown): Option.Option => + Option.map(decodeSelect(data), ({ kind, id }) => ({ kind, id })) + +const isSafeId = (id: string) => /^[A-Za-z0-9_-]{1,64}$/u.test(id) + +/** Inert in the frame: it only posts what was clicked; the frame has no same-origin access. */ +const SELECT_SCRIPT = `(function(){var q="[data-component-id],[data-incident-id]";document.addEventListener("click",function(e){var t=e.target&&e.target.closest?e.target.closest(q):null;if(!t)return;e.preventDefault();var c=t.getAttribute("data-component-id");parent.postMessage(c?{type:"select",kind:"component",id:c}:{type:"select",kind:"incident",id:t.getAttribute("data-incident-id")},"*")});var s=document.querySelector("[data-sp-first]");if(s)window.scrollTo(0,Math.max(0,s.getBoundingClientRect().top+window.scrollY-window.innerHeight/3))})();` + +const selectionStyles = (selectedIds: ReadonlyArray): string => { + const ids = selectedIds.filter(isSafeId) + const selected = ids + .flatMap((id) => [`[data-component-id="${id}"]`, `[data-incident-id="${id}"]`]) + .join(",") + return [ + `[data-component-id],[data-incident-id]{cursor:pointer;border-radius:6px;transition:outline-color .12s}`, + `[data-component-id]:hover,[data-incident-id]:hover{outline:1px dashed color-mix(in srgb,currentColor 35%,transparent);outline-offset:3px}`, + selected === "" ? "" : `${selected}{outline:2px solid #3b82f6;outline-offset:3px}`, + ].join("") +} + +/** Marks the first selected element so the frame scrolls its own view to it after a reload. */ +const markFirst = (html: string, selectedIds: ReadonlyArray): string => { + const first = selectedIds.find(isSafeId) + if (first === undefined) return html + const attr = html.includes(`data-component-id="${first}"`) + ? `data-component-id="${first}"` + : `data-incident-id="${first}"` + return html.replace(attr, `${attr} data-sp-first`) +} + +export type PreviewDocumentOptions = Readonly<{ + snapshot: StatusPageSnapshot + nowMs: number + /** The editor's preview: components and incidents can be clicked, and the selection drawn. */ + isSelectable: boolean + selectedIds: ReadonlyArray +}> + +/** The public page as the renderer draws it, plus, when selectable, the selection script. */ +export const previewDocument = (options: PreviewDocumentOptions): string => { + const html = renderPage(options.snapshot, { _tag: "live", updatedAt: options.nowMs }, options.nowMs, { + preview: options.isSelectable, + }) + if (!options.isSelectable) return html + const extra = `` + return markFirst(html, options.selectedIds).replace("", `${extra}`) +} + +export const PREVIEW_FRAME_ID = "status-page-preview-frame" + +/** The selections the editor's preview frame posts; other windows and shapes are dropped. */ +export const previewSelections = (): Stream.Stream => + Stream.fromEventListener(window, "message").pipe( + Stream.filter((event) => { + const frame = document.getElementById(PREVIEW_FRAME_ID) + // A sandboxed frame without `allow-same-origin` posts from the opaque origin "null". + return ( + frame instanceof HTMLIFrameElement && + event.source === frame.contentWindow && + event.origin === "null" + ) + }), + Stream.flatMap((event) => + Option.match(selectionOf(event.data), { + onNone: () => Stream.empty, + onSome: (selection) => Stream.make(selection), + }), + ), + ) + +/** The sandboxed frame: scripts run so the page pages its bars, but it can reach nothing of ours. */ +export const previewFrame = ( + h: HtmlBuilder, + options: Readonly<{ document: string; title: string; className?: string; isSelectable?: boolean }>, +): Html => + h.iframe( + [ + ...(options.isSelectable === true ? [h.Id(PREVIEW_FRAME_ID)] : []), + h.Title(options.title), + h.Sandbox("allow-scripts"), + h.Srcdoc(options.document), + h.Class(cn("block w-full rounded-lg border bg-background", options.className)), + ], + [], + ) From e40ca34354cb28819138f85ceccc68d400705859 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 23:35:57 +0100 Subject: [PATCH 05/12] feat(web): the status page is its own editor The page renders as visitors get it; a component or incident picked in the preview or in the On this page list opens in a side panel. Components save as you go, the incident composer and updates live in the panel, Settings and JSON are views of their own, and a published page lands on a live screen with its address and next steps. --- .../alerts/status-pages/editor/form.test.ts | 111 --- .../page/alerts/status-pages/editor/form.ts | 401 ----------- .../status-pages/editor/update-components.ts | 184 ----- .../status-pages/editor/view-components.ts | 316 --------- .../status-pages/editor/view-incidents.ts | 502 ------------- .../page/alerts/status-pages/editor/view.ts | 273 ------- apps/web/src/page/registry.ts | 8 +- .../status-pages/editor/command.ts | 73 +- .../status-pages/editor/config-json.test.ts | 2 +- .../status-pages/editor/config-json.ts | 0 .../status-pages/editor/editor.scene.test.ts | 357 +++++++--- .../{alerts => }/status-pages/editor/index.ts | 10 +- .../status-pages/editor/message.ts | 45 +- .../{alerts => }/status-pages/editor/model.ts | 56 +- .../{alerts => }/status-pages/editor/reads.ts | 12 +- .../page/status-pages/editor/subscription.ts | 42 ++ .../status-pages/editor/update-components.ts | 316 +++++++++ .../status-pages/editor/update-incidents.ts | 145 ++-- .../status-pages/editor/update-json.ts | 8 +- .../status-pages/editor/update-settings.ts | 41 +- .../status-pages/editor/update.ts | 251 +++++-- .../status-pages/editor/view-appearance.ts | 171 +---- .../page/status-pages/editor/view-canvas.ts | 386 ++++++++++ .../status-pages/editor/view-json.ts | 18 +- .../src/page/status-pages/editor/view-live.ts | 165 +++++ .../page/status-pages/editor/view-panel.ts | 667 ++++++++++++++++++ .../status-pages/editor/view-settings.ts | 36 +- apps/web/src/page/status-pages/editor/view.ts | 291 ++++++++ 28 files changed, 2669 insertions(+), 2218 deletions(-) delete mode 100644 apps/web/src/page/alerts/status-pages/editor/form.test.ts delete mode 100644 apps/web/src/page/alerts/status-pages/editor/form.ts delete mode 100644 apps/web/src/page/alerts/status-pages/editor/update-components.ts delete mode 100644 apps/web/src/page/alerts/status-pages/editor/view-components.ts delete mode 100644 apps/web/src/page/alerts/status-pages/editor/view-incidents.ts delete mode 100644 apps/web/src/page/alerts/status-pages/editor/view.ts rename apps/web/src/page/{alerts => }/status-pages/editor/command.ts (72%) rename apps/web/src/page/{alerts => }/status-pages/editor/config-json.test.ts (91%) rename apps/web/src/page/{alerts => }/status-pages/editor/config-json.ts (100%) rename apps/web/src/page/{alerts => }/status-pages/editor/editor.scene.test.ts (76%) rename apps/web/src/page/{alerts => }/status-pages/editor/index.ts (57%) rename apps/web/src/page/{alerts => }/status-pages/editor/message.ts (75%) rename apps/web/src/page/{alerts => }/status-pages/editor/model.ts (65%) rename apps/web/src/page/{alerts => }/status-pages/editor/reads.ts (94%) create mode 100644 apps/web/src/page/status-pages/editor/subscription.ts create mode 100644 apps/web/src/page/status-pages/editor/update-components.ts rename apps/web/src/page/{alerts => }/status-pages/editor/update-incidents.ts (63%) rename apps/web/src/page/{alerts => }/status-pages/editor/update-json.ts (96%) rename apps/web/src/page/{alerts => }/status-pages/editor/update-settings.ts (89%) rename apps/web/src/page/{alerts => }/status-pages/editor/update.ts (54%) rename apps/web/src/page/{alerts => }/status-pages/editor/view-appearance.ts (59%) create mode 100644 apps/web/src/page/status-pages/editor/view-canvas.ts rename apps/web/src/page/{alerts => }/status-pages/editor/view-json.ts (92%) create mode 100644 apps/web/src/page/status-pages/editor/view-live.ts create mode 100644 apps/web/src/page/status-pages/editor/view-panel.ts rename apps/web/src/page/{alerts => }/status-pages/editor/view-settings.ts (90%) create mode 100644 apps/web/src/page/status-pages/editor/view.ts diff --git a/apps/web/src/page/alerts/status-pages/editor/form.test.ts b/apps/web/src/page/alerts/status-pages/editor/form.test.ts deleted file mode 100644 index ca1d0601d1..0000000000 --- a/apps/web/src/page/alerts/status-pages/editor/form.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { DEFAULT_STATUS_PAGE_THEME } from "@maple/domain/status-page" -import { describe, expect, it } from "vitest" -import { - hasSettingsIssues, - isSameSettings, - type SettingsForm, - settingsIssues, - settingsPayload, - themeFormOf, -} from "./form" - -const saved: SettingsForm = { - name: "Acme!", - slug: "acme", - description: "", - websiteUrl: "https://acme.example", - privacyUrl: "", - termsUrl: "", - logoUrl: "", - showPoweredBy: true, - autoIncidents: "off", - theme: themeFormOf({ ...DEFAULT_STATUS_PAGE_THEME, accent: "#aabbcc" }), -} - -describe("isSameSettings", () => { - it("treats a draft the API stored trimmed and lower-cased as saved", () => { - const draft = { - ...saved, - name: "Acme! ", - websiteUrl: " https://acme.example", - theme: { ...saved.theme, accent: " #AABBCC" }, - } - expect(isSameSettings(draft, saved)).toBe(true) - }) - - it("still sees a real change", () => { - expect(isSameSettings({ ...saved, theme: { ...saved.theme, accent: "#aabbcd" } }, saved)).toBe(false) - expect(isSameSettings({ ...saved, theme: { ...saved.theme, radius: "lg" } }, saved)).toBe(false) - }) -}) - -describe("footer links", () => { - it("sends https links trimmed and blank ones as null", () => { - const payload = settingsPayload({ - ...saved, - privacyUrl: " https://acme.example/privacy ", - termsUrl: " ", - }) - expect(payload.privacy_url).toBe("https://acme.example/privacy") - expect(payload.terms_url).toBeNull() - }) - - it("refuses links the API would refuse", () => { - const issues = settingsIssues({ - ...saved, - privacyUrl: "http://acme.example/privacy", - termsUrl: "javascript:alert(1)", - }) - expect(issues.privacyUrl).toBe("Use an https URL.") - expect(issues.termsUrl).toBe("Use an https URL.") - expect(hasSettingsIssues({ ...saved, termsUrl: "https://acme.example/terms" })).toBe(false) - }) - - it("sees a changed link as unsaved", () => { - expect(isSameSettings({ ...saved, termsUrl: "https://acme.example/terms" }, saved)).toBe(false) - expect(isSameSettings({ ...saved, termsUrl: " " }, saved)).toBe(true) - }) -}) - -describe("URL fields", () => { - it("refuses what the service refuses: unparseable or not https, on every URL field", () => { - const issues = settingsIssues({ - ...saved, - websiteUrl: "http://acme.example", - privacyUrl: "https://acme.example:abc", - termsUrl: "https://acme.example/terms", - logoUrl: "", - }) - expect(issues.websiteUrl).toBe("Use an https URL.") - expect(issues.privacyUrl).toBe("Use a valid URL, like https://acme.example.") - expect(issues.termsUrl).toBeNull() - expect(issues.logoUrl).toBeNull() - }) - - it("refuses a link longer than the service stores", () => { - const long = `https://acme.example/${"a".repeat(2048)}` - expect(settingsIssues({ ...saved, termsUrl: long }).termsUrl).toBe("Keep it under 2048 characters.") - }) -}) - -describe("theme settings", () => { - it("sends the whole theme with blank colours as null", () => { - const form: SettingsForm = { - ...saved, - theme: { ...saved.theme, appearance: "dark", background: "#0B0B0B " }, - } - expect(settingsPayload(form).theme).toEqual({ - ...DEFAULT_STATUS_PAGE_THEME, - appearance: "dark", - accent: "#aabbcc", - background: "#0b0b0b", - }) - }) - - it("blocks a bad hex and unreadable text, but not overrides Auto ignores", () => { - expect(hasSettingsIssues({ ...saved, theme: { ...saved.theme, card: "teal" } })).toBe(true) - const lowContrast = { ...saved.theme, text: "#777777", background: "#ffffff" } - expect(hasSettingsIssues({ ...saved, theme: { ...lowContrast, appearance: "light" } })).toBe(true) - expect(hasSettingsIssues({ ...saved, theme: lowContrast })).toBe(false) - }) -}) diff --git a/apps/web/src/page/alerts/status-pages/editor/form.ts b/apps/web/src/page/alerts/status-pages/editor/form.ts deleted file mode 100644 index e4dde9291f..0000000000 --- a/apps/web/src/page/alerts/status-pages/editor/form.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { - StatusPageAutoIncidents, - StatusPageComponentId, - StatusPageComponentStatus, - StatusPageIncidentId, - StatusPageIncidentImpact, - StatusPageIncidentKind, - StatusPageIncidentStatus, - UptimeMonitorId, -} from "@maple/domain" -import type { - V2StatusPage, - V2StatusPageComponent, - V2StatusPageComponentCreateParams, - V2StatusPageIncidentCreateParams, - V2StatusPageUpdateParams, -} from "@maple/domain/http/v2" -import { - STATUS_PAGE_HEX_COLOR_PATTERN, - StatusPageThemeAppearance, - StatusPageThemeFont, - StatusPageThemePreset, - StatusPageThemeRadius, - type StatusPageTheme, - themeContrastIssue, - STATUS_PAGE_MAX_URL_LENGTH, -} from "@maple/domain/status-page" -import { DateTime, Option, Schema } from "effect" -import { closingStatus, STATUSES_OF_KIND } from "../../shared/status-page-derive" -import { slugIssue } from "../slug" - -// The editor's drafts: each form as its inputs hold it, and the payloads built from them. - -// COMPONENTS - -export const ComponentTextField = Schema.Literals([ - "name", - "description", - "group", - "monitorId", - "manualStatus", -]) -export type ComponentTextField = typeof ComponentTextField.Type - -export const ComponentForm = Schema.Struct({ - /** The component being edited; `null` adds one. */ - editingId: Schema.NullOr(StatusPageComponentId), - name: Schema.String, - description: Schema.String, - group: Schema.String, - /** The linked monitor's id, `""` for none. */ - monitorId: Schema.String, - /** The override, `""` to follow the monitor and incidents. */ - manualStatus: Schema.String, - showUptime: Schema.Boolean, -}) -export type ComponentForm = typeof ComponentForm.Type - -export const emptyComponentForm: ComponentForm = { - editingId: null, - name: "", - description: "", - group: "", - monitorId: "", - manualStatus: "", - showUptime: true, -} - -export const componentFormOf = (component: V2StatusPageComponent): ComponentForm => ({ - editingId: component.id, - name: component.name, - description: component.description ?? "", - group: component.group ?? "", - monitorId: component.monitor_id ?? "", - manualStatus: component.manual_status ?? "", - showUptime: component.show_uptime, -}) - -const orNull = (value: string): string | null => (value.trim() === "" ? null : value.trim()) -const decodeMonitorId = Schema.decodeUnknownOption(UptimeMonitorId) -const decodeComponentStatus = Schema.decodeUnknownOption(StatusPageComponentStatus) - -/** The whole component, sent on create and update alike so a cleared field is cleared. */ -export const componentPayload = (form: ComponentForm): V2StatusPageComponentCreateParams => ({ - name: form.name.trim(), - description: orNull(form.description), - group: orNull(form.group), - monitor_id: Option.getOrNull(decodeMonitorId(form.monitorId)), - manual_status: Option.getOrNull(decodeComponentStatus(form.manualStatus)), - show_uptime: form.showUptime, -}) - -// INCIDENTS - -export const IncidentTextField = Schema.Literals([ - "title", - "impact", - "status", - "body", - "scheduledStart", - "scheduledEnd", -]) -export type IncidentTextField = typeof IncidentTextField.Type - -export const IncidentForm = Schema.Struct({ - kind: StatusPageIncidentKind, - title: Schema.String, - impact: StatusPageIncidentImpact, - status: StatusPageIncidentStatus, - componentIds: Schema.Array(StatusPageComponentId), - body: Schema.String, - /** `false` saves a draft visitors do not see. */ - isPublished: Schema.Boolean, - /** Maintenance only: the window as `datetime-local` values, in the viewer's time zone. */ - scheduledStart: Schema.String, - scheduledEnd: Schema.String, -}) -export type IncidentForm = typeof IncidentForm.Type - -export const emptyIncidentForm = (kind: StatusPageIncidentKind): IncidentForm => ({ - kind, - title: "", - impact: kind === "incident" ? "minor" : "none", - status: kind === "incident" ? "investigating" : "scheduled", - componentIds: [], - body: "", - isPublished: true, - scheduledStart: "", - scheduledEnd: "", -}) - -const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/u - -/** A `datetime-local` value read as wall-clock time in `timeZone`, as an ISO instant. */ -export const localToIso = (value: string, timeZone: string): Option.Option => { - const match = LOCAL_DATE_TIME.exec(value) - if (match === null) return Option.none() - const [year, month, day, hour, minute] = match.slice(1).map(Number) - return Option.map( - DateTime.makeZoned({ year, month, day, hour, minute }, { timeZone, adjustForTimeZone: true }), - DateTime.formatIso, - ) -} - -/** What stops the incident from being sent, in reading order; empty when it can go. */ -export const incidentIssues = (form: IncidentForm, timeZone: string): ReadonlyArray => { - const start = localToIso(form.scheduledStart, timeZone) - const end = localToIso(form.scheduledEnd, timeZone) - const windowIssue = - form.kind !== "maintenance" - ? null - : Option.isNone(start) || Option.isNone(end) - ? "Set when the maintenance starts and ends." - : start.value >= end.value - ? "The maintenance must end after it starts." - : null - return [ - ...(form.title.trim() === "" ? ["Add a title."] : []), - ...(form.body.trim() === "" ? ["Write the first update."] : []), - ...(windowIssue === null ? [] : [windowIssue]), - ] -} - -export const incidentPayload = ( - form: IncidentForm, - pageId: V2StatusPage["id"], - timeZone: string, -): V2StatusPageIncidentCreateParams => { - const base: V2StatusPageIncidentCreateParams = { - page_id: pageId, - kind: form.kind, - title: form.title.trim(), - impact: form.impact, - status: form.status, - body: form.body.trim(), - component_ids: form.componentIds, - published: form.isPublished, - } - if (form.kind !== "maintenance") return base - return Option.match( - Option.all([localToIso(form.scheduledStart, timeZone), localToIso(form.scheduledEnd, timeZone)]), - { - onNone: () => base, - onSome: ([start, end]) => ({ ...base, scheduled_start: start, scheduled_end: end }), - }, - ) -} - -// UPDATES - -export const UpdateForm = Schema.Struct({ - incidentId: StatusPageIncidentId, - kind: StatusPageIncidentKind, - status: StatusPageIncidentStatus, - body: Schema.String, -}) -export type UpdateForm = typeof UpdateForm.Type - -/** The next status along, or the closing one for Resolve. */ -export const updateFormFor = ( - incident: { - readonly id: StatusPageIncidentId - readonly kind: StatusPageIncidentKind - readonly status: StatusPageIncidentStatus - }, - isResolving: boolean, -): UpdateForm => { - const statuses: ReadonlyArray = STATUSES_OF_KIND[incident.kind] - const next = statuses[statuses.indexOf(incident.status) + 1] ?? incident.status - const status = isResolving ? closingStatus(incident.kind) : next - return { - incidentId: incident.id, - kind: incident.kind, - status, - body: isResolving - ? incident.kind === "incident" - ? "This incident has been resolved." - : "The maintenance is complete." - : "", - } -} - -// SETTINGS - -export const SettingsTextField = Schema.Literals([ - "name", - "slug", - "description", - "websiteUrl", - "privacyUrl", - "termsUrl", - "logoUrl", - "autoIncidents", -]) -export type SettingsTextField = typeof SettingsTextField.Type - -/** The theme's colour inputs, as typed; blank means the preset's colour. */ -export const ThemeColorField = Schema.Literals(["accent", "background", "text", "card", "border"]) -export type ThemeColorField = typeof ThemeColorField.Type - -/** The colours Auto ignores: they apply only to a forced Light or Dark page. */ -export const THEME_OVERRIDE_FIELDS = ["background", "text", "card", "border"] as const - -export const ThemeForm = Schema.Struct({ - preset: StatusPageThemePreset, - appearance: StatusPageThemeAppearance, - accent: Schema.String, - background: Schema.String, - text: Schema.String, - card: Schema.String, - border: Schema.String, - radius: StatusPageThemeRadius, - font: StatusPageThemeFont, -}) -export type ThemeForm = typeof ThemeForm.Type - -export const SettingsForm = Schema.Struct({ - name: Schema.String, - slug: Schema.String, - description: Schema.String, - websiteUrl: Schema.String, - privacyUrl: Schema.String, - termsUrl: Schema.String, - logoUrl: Schema.String, - showPoweredBy: Schema.Boolean, - autoIncidents: StatusPageAutoIncidents, - theme: ThemeForm, -}) -export type SettingsForm = typeof SettingsForm.Type - -export const themeFormOf = (theme: StatusPageTheme): ThemeForm => ({ - ...theme, - accent: theme.accent ?? "", - background: theme.background ?? "", - text: theme.text ?? "", - card: theme.card ?? "", - border: theme.border ?? "", -}) - -export const settingsFormOf = (page: V2StatusPage): SettingsForm => ({ - name: page.name, - slug: page.slug, - description: page.description ?? "", - websiteUrl: page.website_url ?? "", - privacyUrl: page.privacy_url ?? "", - termsUrl: page.terms_url ?? "", - logoUrl: page.logo_url ?? "", - showPoweredBy: page.show_powered_by, - autoIncidents: page.auto_incidents, - theme: themeFormOf(page.theme), -}) - -const normalizedColor = (value: string): string => value.trim().toLowerCase() - -/** The theme as the API stores it: blank colours null, the rest trimmed and lower-cased. */ -export const themeOf = (form: ThemeForm): StatusPageTheme => ({ - ...form, - accent: orNull(normalizedColor(form.accent)), - background: orNull(normalizedColor(form.background)), - text: orNull(normalizedColor(form.text)), - card: orNull(normalizedColor(form.card)), - border: orNull(normalizedColor(form.border)), -}) - -/** The values the API stores: trimmed, the colours lower-cased, as `settingsPayload` sends them. */ -const normalizedSettings = (form: SettingsForm): SettingsForm => ({ - ...form, - name: form.name.trim(), - description: form.description.trim(), - websiteUrl: form.websiteUrl.trim(), - privacyUrl: form.privacyUrl.trim(), - termsUrl: form.termsUrl.trim(), - logoUrl: form.logoUrl.trim(), - theme: themeFormOf(themeOf(form.theme)), -}) - -/** Same once normalized, so a saved draft whose input differed only in case or spaces settles. */ -export const isSameSettings = (left: SettingsForm, right: SettingsForm): boolean => - sameNormalized(normalizedSettings(left), normalizedSettings(right)) - -const sameNormalized = (a: SettingsForm, b: SettingsForm): boolean => - a.name === b.name && - a.slug === b.slug && - a.description === b.description && - a.websiteUrl === b.websiteUrl && - a.privacyUrl === b.privacyUrl && - a.termsUrl === b.termsUrl && - a.logoUrl === b.logoUrl && - a.showPoweredBy === b.showPoweredBy && - a.autoIncidents === b.autoIncidents && - sameTheme(a.theme, b.theme) - -const sameTheme = (a: ThemeForm, b: ThemeForm): boolean => - a.preset === b.preset && - a.appearance === b.appearance && - a.accent === b.accent && - a.background === b.background && - a.text === b.text && - a.card === b.card && - a.border === b.border && - a.radius === b.radius && - a.font === b.font -/** The service's rule: a URL `URL` can parse, over https. Checked here so Save never sends what it refuses. */ -const httpsIssue = (value: string): string | null => { - const url = value.trim() - if (url === "") return null - if (url.length > STATUS_PAGE_MAX_URL_LENGTH) - return `Keep it under ${STATUS_PAGE_MAX_URL_LENGTH} characters.` - if (!URL.canParse(url)) return "Use a valid URL, like https://acme.example." - return new URL(url).protocol === "https:" ? null : "Use an https URL." -} - -/** Each field's problem, or `null`: the API refuses the same values. */ -export const settingsIssues = (form: SettingsForm): Readonly> => ({ - name: form.name.trim() === "" ? "Add a name." : null, - slug: slugIssue(form.slug), - description: null, - websiteUrl: httpsIssue(form.websiteUrl), - privacyUrl: httpsIssue(form.privacyUrl), - termsUrl: httpsIssue(form.termsUrl), - logoUrl: httpsIssue(form.logoUrl), - autoIncidents: null, -}) - -const colorIssue = (value: string): string | null => - value.trim() === "" || STATUS_PAGE_HEX_COLOR_PATTERN.test(value.trim()) - ? null - : "Use a hex color like #1a2b3c." - -/** Each colour input's problem, or `null`. */ -export const themeColorIssues = (theme: ThemeForm): Readonly> => ({ - accent: colorIssue(theme.accent), - background: colorIssue(theme.background), - text: colorIssue(theme.text), - card: colorIssue(theme.card), - border: colorIssue(theme.border), -}) - -/** Unreadable text, as the API would refuse it; checked once every colour is a valid hex. */ -export const themeContrastOf = (theme: ThemeForm): string | null => - Object.values(themeColorIssues(theme)).some((issue) => issue !== null) - ? null - : themeContrastIssue(themeOf(theme)) - -export const hasSettingsIssues = (form: SettingsForm): boolean => - Object.values(settingsIssues(form)).some((issue) => issue !== null) || - Object.values(themeColorIssues(form.theme)).some((issue) => issue !== null) || - themeContrastOf(form.theme) !== null - -export const settingsPayload = (form: SettingsForm): V2StatusPageUpdateParams => ({ - name: form.name.trim(), - slug: form.slug, - description: orNull(form.description), - website_url: orNull(form.websiteUrl), - privacy_url: orNull(form.privacyUrl), - terms_url: orNull(form.termsUrl), - logo_url: orNull(form.logoUrl), - theme: themeOf(form.theme), - show_powered_by: form.showPoweredBy, - auto_incidents: form.autoIncidents, -}) diff --git a/apps/web/src/page/alerts/status-pages/editor/update-components.ts b/apps/web/src/page/alerts/status-pages/editor/update-components.ts deleted file mode 100644 index 91652f03b7..0000000000 --- a/apps/web/src/page/alerts/status-pages/editor/update-components.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { StatusPageComponentId } from "@maple/domain" -import type { V2StatusPageComponent } from "@maple/domain/http/v2" -import { Result } from "effect" -import { AsyncData } from "foldkit" -import type { LoadFailure } from "../../../../data/load-failure" -import * as Dialog from "../../../../ui/dialog" -import type { PageContext } from "../../../contract" -import { DeleteComponent, ReorderComponents, SaveComponent } from "./command" -import { type ComponentTextField, componentFormOf, componentPayload, emptyComponentForm } from "./form" -import { Message } from "./message" -import type { Model } from "./model" -import { - chain, - componentsOf, - errorToast, - liftDialog, - readComponents, - readPage, - type Return, - toast, -} from "./reads" - -// The Components tab: add and edit in a dialog, delete behind a confirm, move up and down. - -const componentDialog = (model: Model, step: ReturnType): Return => - liftDialog( - model, - step, - (next, componentDialog) => ({ ...next, componentDialog }), - (message) => Message.GotComponentDialogMessage({ message }), - ) - -export const deleteComponentDialog = (model: Model, step: ReturnType): Return => { - const lifted = liftDialog( - model, - step, - (next, deleteComponentDialog) => ({ ...next, deleteComponentDialog }), - (message) => Message.GotDeleteComponentDialogMessage({ message }), - ) - return step.outMessage?._tag === "Closed" - ? { ...lifted, model: { ...lifted.model, confirmDeleteComponentId: null } } - : lifted -} - -export const openComponent = (model: Model, componentId: StatusPageComponentId | null): Return => { - const existing = componentsOf(model).find((component) => component.id === componentId) - const form = existing === undefined ? emptyComponentForm : componentFormOf(existing) - return componentDialog( - { ...model, componentForm: form, componentFailure: null }, - Dialog.open(model.componentDialog), - ) -} - -export const changeComponent = (model: Model, field: ComponentTextField, value: string): Return => ({ - model: { ...model, componentForm: { ...model.componentForm, [field]: value } }, -}) - -export const submitComponent = (model: Model): Return => - model.pageId === null || model.isSavingComponent || model.componentForm.name.trim() === "" - ? { model } - : { - model: { ...model, isSavingComponent: true, componentFailure: null }, - commands: [ - SaveComponent({ - pageId: model.pageId, - componentId: model.componentForm.editingId, - payload: componentPayload(model.componentForm), - }), - ], - } - -/** Components and the page revision change together: both are read again. */ -const reread = (model: Model, context: PageContext): Return => - chain(model, readComponents(context, "Supersede"), readPage(context, "Supersede")) - -export const savedComponent = ( - model: Model, - result: Result.Result, - context: PageContext, -): Return => { - const settled = { ...model, isSavingComponent: false } - return Result.match(result, { - onSuccess: (component): Return => { - const closed = componentDialog(settled, Dialog.close(settled.componentDialog)) - const refreshed = reread(closed.model, context) - return { - model: refreshed.model, - commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], - outMessage: toast( - "success", - model.componentForm.editingId === null ? "Component added" : "Component saved", - component.name, - ), - } - }, - onFailure: (componentFailure): Return => ({ model: { ...settled, componentFailure } }), - }) -} - -/** The list with `componentId` moved by `offset`, or `null` when it cannot move that way. */ -export const moved = ( - components: ReadonlyArray, - componentId: StatusPageComponentId, - offset: number, -): ReadonlyArray | null => { - const from = components.findIndex((component) => component.id === componentId) - const to = from + offset - const item = components[from] - if (from < 0 || to < 0 || to >= components.length || item === undefined) return null - const rest = components.filter((_, index) => index !== from) - return [...rest.slice(0, to), item, ...rest.slice(to)] -} - -/** Moves the row at once; the server's order lands with the re-read. */ -export const moveComponent = (model: Model, componentId: StatusPageComponentId, offset: number): Return => { - const next = moved(componentsOf(model), componentId, offset) - if (model.pageId === null || model.isReordering || next === null) return { model } - return { - model: { - ...model, - isReordering: true, - components: { ...model.components, data: AsyncData.map(model.components.data, () => next) }, - }, - commands: [ - ReorderComponents({ pageId: model.pageId, componentIds: next.map((component) => component.id) }), - ], - } -} - -export const reordered = ( - model: Model, - result: Result.Result, LoadFailure>, - context: PageContext, -): Return => { - const refreshed = reread({ ...model, isReordering: false }, context) - return Result.match(result, { - onSuccess: (): Return => refreshed, - onFailure: (failure): Return => ({ - ...refreshed, - outMessage: errorToast("Couldn't reorder the components", failure), - }), - }) -} - -export const askDeleteComponent = (model: Model, componentId: StatusPageComponentId): Return => - deleteComponentDialog( - { ...model, confirmDeleteComponentId: componentId }, - Dialog.open(model.deleteComponentDialog), - ) - -export const confirmDeleteComponent = (model: Model): Return => { - const id = model.confirmDeleteComponentId - return model.pageId === null || id === null || model.isDeletingComponent - ? { model } - : { - model: { ...model, isDeletingComponent: true }, - commands: [DeleteComponent({ pageId: model.pageId, componentId: id })], - } -} - -export const deletedComponent = ( - model: Model, - result: Result.Result, - context: PageContext, -): Return => { - const settled = { ...model, isDeletingComponent: false } - return Result.match(result, { - onSuccess: (): Return => { - const closed = deleteComponentDialog(settled, Dialog.close(settled.deleteComponentDialog)) - const refreshed = reread(closed.model, context) - return { - model: refreshed.model, - commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], - outMessage: toast("success", "Component removed"), - } - }, - onFailure: (failure): Return => ({ - model: settled, - outMessage: errorToast("Couldn't remove the component", failure), - }), - }) -} - -export { componentDialog } diff --git a/apps/web/src/page/alerts/status-pages/editor/view-components.ts b/apps/web/src/page/alerts/status-pages/editor/view-components.ts deleted file mode 100644 index add5ac37dd..0000000000 --- a/apps/web/src/page/alerts/status-pages/editor/view-components.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { StatusPageComponentStatus } from "@maple/domain" -import type { V2StatusPageComponent, V2UptimeMonitor } from "@maple/domain/http/v2" -import { TONE_FILL } from "@maple/ui/lib/tone" -import { cn } from "@maple/ui/lib/utils" -import type { Html, HtmlBuilder } from "foldkit/html" -import { alert, alertDescription, alertTitle } from "../../../../ui/alert" -import { button } from "../../../../ui/button" -import * as Checkbox from "../../../../ui/checkbox" -import { confirmDialog } from "../../../../ui/confirm-dialog" -import { badge } from "../../../../ui/display" -import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../../ui/empty" -import { formDialog } from "../../../../ui/form-dialog" -import { icon } from "../../../../ui/icon" -import { iconButton } from "../../../../ui/icon-button" -import { failureMessage } from "../../../../data/load-failure" -import { sectionHeading } from "../../../kit/section-heading" -import { emptyActions } from "../../../kit/signal-empty-state" -import { - COMPONENT_STATUS_LABEL, - COMPONENT_STATUS_TONE, - componentStatus, -} from "../../shared/status-page-derive" -import { selectField, textareaField, textField } from "../fields" -import { Message } from "./message" -import type { Model } from "./model" -import { componentsOf, incidentsOf, monitorsOf } from "./reads" - -// The Components tab: each line of the page in order, and the add or edit dialog. - -type H = HtmlBuilder - -const statusChip = (h: H, status: StatusPageComponentStatus): Html => - h.span( - [h.Class("flex items-center gap-1.5 text-xs text-muted-foreground")], - [ - h.span( - [ - h.AriaHidden(true), - h.Class( - cn( - "inline-block size-2 shrink-0 rounded-[2px]", - TONE_FILL[COMPONENT_STATUS_TONE[status]], - ), - ), - ], - [], - ), - COMPONENT_STATUS_LABEL[status], - ], - ) - -/** What drives the line: its monitor, the override, or nothing but incidents. */ -const sourceLine = (component: V2StatusPageComponent, monitors: ReadonlyArray): string => { - const monitor = monitors.find((row) => row.id === component.monitor_id) - const parts = [ - component.monitor_id === null ? "No monitor" : `Monitor ${monitor?.name ?? "not found"}`, - ...(component.manual_status === null - ? [] - : [`set to ${COMPONENT_STATUS_LABEL[component.manual_status].toLowerCase()}`]), - ...(component.show_uptime && component.monitor_id !== null ? ["uptime bars shown"] : []), - ] - return parts.join(" · ") -} - -const rowActions = ( - h: H, - model: Model, - component: V2StatusPageComponent, - index: number, - count: number, -): Html => - h.div( - [h.Class("flex shrink-0 items-center gap-0.5")], - [ - iconButton( - h, - { - label: `Move ${component.name} up`, - isDisabled: index === 0 || model.isReordering, - attributes: [ - h.OnClick(Message.ClickedMoveComponent({ componentId: component.id, offset: -1 })), - ], - }, - [icon(h, "ArrowUpIcon")], - ), - iconButton( - h, - { - label: `Move ${component.name} down`, - isDisabled: index === count - 1 || model.isReordering, - attributes: [ - h.OnClick(Message.ClickedMoveComponent({ componentId: component.id, offset: 1 })), - ], - }, - [icon(h, "ArrowDownIcon")], - ), - iconButton( - h, - { - label: `Edit ${component.name}`, - attributes: [h.OnClick(Message.ClickedEditComponent({ componentId: component.id }))], - }, - [icon(h, "PencilIcon")], - ), - iconButton( - h, - { - label: `Remove ${component.name}`, - attributes: [h.OnClick(Message.ClickedDeleteComponent({ componentId: component.id }))], - }, - [icon(h, "TrashIcon")], - ), - ], - ) - -const componentRow = ( - h: H, - model: Model, - component: V2StatusPageComponent, - index: number, - count: number, -): Html => { - const monitors = monitorsOf(model) - return h.li( - [h.Key(component.id), h.Class("flex items-center gap-4 py-3 pr-2 pl-4")], - [ - h.div( - [h.Class("min-w-0 flex-1")], - [ - h.div( - [h.Class("flex min-w-0 items-center gap-2")], - [ - h.span([h.Class("truncate text-sm font-medium")], [component.name]), - ...(component.group === null - ? [] - : [badge(h, { variant: "secondary" }, [component.group])]), - ], - ), - h.p( - [h.Class("truncate text-xs text-muted-foreground")], - [sourceLine(component, monitors)], - ), - ], - ), - statusChip(h, componentStatus(component, monitors, incidentsOf(model))), - ...(model.isAdmin ? [rowActions(h, model, component, index, count)] : []), - ], - ) -} - -const emptyComponents = (h: H, model: Model): Html => - empty( - h, - [ - emptyHeader(h, [ - emptyMedia(h, { variant: "icon" }, [icon(h, "LayersIcon", { size: 18 })]), - emptyTitle(h, ["No components yet"]), - emptyDescription(h, [ - "Components are the lines visitors see, like API or Dashboard. Link one to an uptime monitor and it goes down when the monitor does.", - ]), - ]), - ...(model.isAdmin - ? [ - emptyActions(h, [ - button( - h, - { size: "sm", attributes: [h.OnClick(Message.ClickedAddComponent())] }, - [icon(h, "PlusIcon"), "Add component"], - ), - ]), - ] - : []), - ], - "rounded-md border py-12", - ) - -export const componentsTab = (h: H, model: Model): Html => { - const components = componentsOf(model) - return h.section( - [h.AriaLabelledBy("status-page-components"), h.Class("flex flex-col gap-3")], - [ - sectionHeading(h, { - id: "status-page-components", - title: "Components", - count: components.length === 0 ? undefined : String(components.length), - actions: - model.isAdmin && components.length > 0 - ? button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedAddComponent())], - }, - [icon(h, "PlusIcon"), "Add component"], - ) - : undefined, - }), - components.length === 0 - ? emptyComponents(h, model) - : h.ul( - [h.Class("divide-y rounded-md border")], - components.map((component, index) => - componentRow(h, model, component, index, components.length), - ), - ), - ], - ) -} - -const MANUAL_STATUS_ENTRIES = [ - { value: "", label: "Follow the monitor and incidents" }, - ...StatusPageComponentStatus.literals.map((status) => ({ - value: status, - label: COMPONENT_STATUS_LABEL[status], - })), -] - -export const componentDialog = (h: H, model: Model): Html => { - const form = model.componentForm - const monitorEntries = [ - { value: "", label: "None" }, - ...monitorsOf(model).map((monitor) => ({ value: monitor.id, label: monitor.name })), - ] - const uptimeInputId = "status-page-component-uptime" - return formDialog(h, { - slotId: "status-page-component", - model: model.componentDialog, - toParentMessage: (message) => Message.GotComponentDialogMessage({ message }), - title: form.editingId === null ? "Add component" : "Edit component", - description: "Its status is the worst of its monitor, the override and the incidents naming it.", - onSubmit: Message.SubmittedComponent(), - submitLabel: form.editingId === null ? "Add component" : "Save component", - isPending: model.isSavingComponent, - isSubmitDisabled: form.name.trim() === "", - fields: [ - h.div( - [h.Class("flex flex-col gap-4")], - [ - textField(h, { - id: "status-page-component-name", - label: "Name", - value: form.name, - placeholder: "API", - onInput: (value) => Message.ChangedComponentField({ field: "name", value }), - }), - textareaField(h, { - id: "status-page-component-description", - label: "Description", - value: form.description, - hint: "Optional. Shown under the name.", - onInput: (value) => Message.ChangedComponentField({ field: "description", value }), - }), - textField(h, { - id: "status-page-component-group", - label: "Group", - value: form.group, - placeholder: "Core services", - hint: "Optional. Components with the same group are listed under one heading.", - onInput: (value) => Message.ChangedComponentField({ field: "group", value }), - }), - selectField(h, { - id: "status-page-component-monitor", - label: "Uptime monitor", - value: form.monitorId, - entries: monitorEntries, - hint: "A monitor that is down shows the component as a major outage.", - onInput: (value) => Message.ChangedComponentField({ field: "monitorId", value }), - }), - selectField(h, { - id: "status-page-component-status", - label: "Status override", - value: form.manualStatus, - entries: MANUAL_STATUS_ENTRIES, - onInput: (value) => Message.ChangedComponentField({ field: "manualStatus", value }), - }), - h.label( - [h.Class("flex items-center gap-2 text-sm"), h.Id(Checkbox.labelId(uptimeInputId))], - [ - ...Checkbox.view(h, { - model: { isChecked: form.showUptime }, - toParentMessage: () => Message.ToggledComponentUptime(), - id: `${uptimeInputId}-root`, - inputId: uptimeInputId, - }), - "Show 90 days of uptime bars", - ], - ), - ...(model.componentFailure === null - ? [] - : [ - alert(h, { variant: "crit", size: "sm" }, [ - icon(h, "CircleXmarkIcon"), - alertTitle(h, ["Couldn't save the component"]), - alertDescription(h, [failureMessage(model.componentFailure)]), - ]), - ]), - ], - ), - ], - }) -} - -export const deleteComponentDialog = (h: H, model: Model): Html => { - const target = componentsOf(model).find((component) => component.id === model.confirmDeleteComponentId) - return confirmDialog(h, { - slotId: "status-page-delete-component", - model: model.deleteComponentDialog, - toParentMessage: (message) => Message.GotDeleteComponentDialogMessage({ message }), - title: `Remove ${target?.name ?? "component"}?`, - description: "It disappears from the page with the next publish. Incidents keep their history.", - confirmLabel: "Remove component", - isPending: model.isDeletingComponent, - onConfirm: Message.ClickedConfirmDeleteComponent(), - }) -} diff --git a/apps/web/src/page/alerts/status-pages/editor/view-incidents.ts b/apps/web/src/page/alerts/status-pages/editor/view-incidents.ts deleted file mode 100644 index 775a9e58f5..0000000000 --- a/apps/web/src/page/alerts/status-pages/editor/view-incidents.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { StatusPageIncidentImpact } from "@maple/domain" -import type { V2StatusPageIncident } from "@maple/domain/http/v2" -import { formatTimestampInTimezone } from "@maple/web-data/lib/timezone-format" -import type { Html, HtmlBuilder } from "foldkit/html" -import { alert, alertDescription, alertTitle } from "../../../../ui/alert" -import { button } from "../../../../ui/button" -import * as Checkbox from "../../../../ui/checkbox" -import { badge } from "../../../../ui/display" -import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../../ui/empty" -import { formDialog } from "../../../../ui/form-dialog" -import { icon } from "../../../../ui/icon" -import { segmentedSelect } from "../../../../ui/segmented-select" -import { failureMessage } from "../../../../data/load-failure" -import { relativeTime } from "../../../kit/relative-time" -import { sectionHeading } from "../../../kit/section-heading" -import { - closingStatus, - IMPACT_LABEL, - INCIDENT_STATUS_LABEL, - isOpenIncident, - STATUSES_OF_KIND, -} from "../../shared/status-page-derive" -import { selectField, textareaField, textField } from "../fields" -import { incidentIssues } from "./form" -import { Message } from "./message" -import type { Model } from "./model" -import { componentsOf, incidentsOf } from "./reads" - -// The Incidents tab: open incidents and maintenance with their actions, then the closed ones. - -type H = HtmlBuilder - -export type TimeInputs = Readonly<{ nowMs: number; timeZone: string }> - -const IMPACT_TONE = { - none: "muted", - minor: "warn", - major: "warn", - critical: "crit", -} as const satisfies Record - -const componentNames = (model: Model, incident: V2StatusPageIncident): string => { - const names = componentsOf(model) - .filter((component) => incident.component_ids.includes(component.id)) - .map((component) => component.name) - return names.length === 0 ? "No components named" : names.join(", ") -} - -/** "Oct 28, 14:00 to 16:00": the maintenance window in the viewer's zone. */ -const windowLine = (incident: V2StatusPageIncident, timeZone: string): string | null => - incident.scheduled_start === null || incident.scheduled_end === null - ? null - : `${formatTimestampInTimezone(incident.scheduled_start, { timeZone, style: "range" })} to ${formatTimestampInTimezone(incident.scheduled_end, { timeZone, style: "range" })}` - -const incidentBadges = (h: H, incident: V2StatusPageIncident): ReadonlyArray => [ - badge(h, { variant: "outline" }, [incident.kind === "incident" ? "Incident" : "Maintenance"]), - badge(h, { variant: isOpenIncident(incident) ? "info" : "muted" }, [ - INCIDENT_STATUS_LABEL[incident.status], - ]), - ...(incident.kind === "incident" - ? [badge(h, { variant: IMPACT_TONE[incident.impact] }, [`${IMPACT_LABEL[incident.impact]} impact`])] - : []), - ...(incident.published ? [] : [badge(h, { variant: "meta" }, ["Draft"])]), -] - -const openActions = (h: H, model: Model, incident: V2StatusPageIncident): Html => - h.div( - [h.Class("flex shrink-0 items-center gap-2")], - [ - ...(incident.published - ? [] - : [ - button( - h, - { - size: "sm", - variant: "outline", - isLoading: model.publishingIncidentIds.includes(incident.id), - attributes: [ - h.OnClick(Message.ClickedPublishIncident({ incidentId: incident.id })), - ], - }, - ["Publish draft"], - ), - ]), - button( - h, - { - size: "sm", - variant: "outline", - attributes: [ - h.OnClick(Message.ClickedPostUpdate({ incidentId: incident.id, isResolving: false })), - ], - }, - ["Post update"], - ), - button( - h, - { - size: "sm", - variant: "outline", - attributes: [ - h.OnClick(Message.ClickedPostUpdate({ incidentId: incident.id, isResolving: true })), - ], - }, - [incident.kind === "incident" ? "Resolve" : "Complete"], - ), - ], - ) - -const openCard = (h: H, model: Model, incident: V2StatusPageIncident, time: TimeInputs): Html => { - const latest = incident.updates[0] - const window = windowLine(incident, time.timeZone) - return h.li( - [h.Key(incident.id), h.Class("flex flex-col gap-3 p-4")], - [ - h.div( - [h.Class("flex flex-wrap items-start justify-between gap-3")], - [ - h.div( - [h.Class("flex min-w-0 flex-col gap-1.5")], - [ - h.h3([h.Class("text-sm font-medium")], [incident.title]), - h.div( - [h.Class("flex flex-wrap items-center gap-1.5")], - incidentBadges(h, incident), - ), - ], - ), - openActions(h, model, incident), - ], - ), - h.p( - [h.Class("text-xs text-muted-foreground")], - [componentNames(model, incident), ...(window === null ? [] : [` · ${window}`])], - ), - ...(latest === undefined - ? [] - : [ - h.div( - [h.Class("rounded-md bg-muted/40 px-3 py-2")], - [ - h.p([h.Class("text-sm whitespace-pre-wrap")], [latest.body]), - h.p( - [h.Class("mt-1 text-xs text-muted-foreground")], - [ - `${INCIDENT_STATUS_LABEL[latest.status]} · `, - relativeTime(h, { - value: latest.created_at, - nowMs: time.nowMs, - timeZone: time.timeZone, - tooltip: "title", - }), - ], - ), - ], - ), - ]), - ], - ) -} - -const closedRow = (h: H, incident: V2StatusPageIncident, time: TimeInputs): Html => - h.li( - [h.Key(incident.id), h.Class("flex items-center justify-between gap-4 px-4 py-2.5")], - [ - h.div( - [h.Class("flex min-w-0 items-center gap-2")], - [ - h.span([h.Class("truncate text-sm")], [incident.title]), - badge(h, { variant: "outline" }, [ - incident.kind === "incident" ? "Incident" : "Maintenance", - ]), - ], - ), - h.span( - [h.Class("shrink-0 text-xs text-muted-foreground")], - [ - `${INCIDENT_STATUS_LABEL[incident.status]} `, - relativeTime(h, { - value: incident.resolved_at ?? incident.updated_at, - nowMs: time.nowMs, - timeZone: time.timeZone, - tooltip: "title", - }), - ], - ), - ], - ) - -const emptyIncidents = (h: H): Html => - empty( - h, - [ - emptyHeader(h, [ - emptyMedia(h, { variant: "icon" }, [icon(h, "CircleCheckIcon", { size: 18 })]), - emptyTitle(h, ["No incidents"]), - emptyDescription(h, [ - "When something breaks, open an incident and post updates as you learn more. Planned work goes in as scheduled maintenance.", - ]), - ]), - ], - "rounded-md border py-12", - ) - -const headerActions = (h: H): Html => - h.div( - [h.Class("flex items-center gap-2")], - [ - button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewIncident({ kind: "maintenance" }))], - }, - [icon(h, "CalendarIcon"), "Schedule maintenance"], - ), - button( - h, - { size: "sm", attributes: [h.OnClick(Message.ClickedNewIncident({ kind: "incident" }))] }, - [icon(h, "PlusIcon"), "New incident"], - ), - ], - ) - -export const incidentsTab = (h: H, model: Model, time: TimeInputs): Html => { - const incidents = incidentsOf(model) - const open = incidents.filter(isOpenIncident) - const closed = incidents.filter((incident) => !isOpenIncident(incident)) - return h.div( - [h.Class("flex flex-col gap-8")], - [ - h.section( - [h.AriaLabelledBy("status-page-open-incidents"), h.Class("flex flex-col gap-3")], - [ - sectionHeading(h, { - id: "status-page-open-incidents", - title: "Open", - count: open.length === 0 ? undefined : String(open.length), - actions: headerActions(h), - }), - open.length === 0 - ? emptyIncidents(h) - : h.ul( - [h.Class("divide-y rounded-md border")], - open.map((incident) => openCard(h, model, incident, time)), - ), - ], - ), - ...(closed.length === 0 - ? [] - : [ - h.section( - [ - h.AriaLabelledBy("status-page-closed-incidents"), - h.Class("flex flex-col gap-3"), - ], - [ - sectionHeading(h, { id: "status-page-closed-incidents", title: "Recent" }), - h.ul( - [h.Class("divide-y rounded-md border")], - closed.map((incident) => closedRow(h, incident, time)), - ), - ], - ), - ]), - ], - ) -} - -const statusEntries = (kind: V2StatusPageIncident["kind"], isOpening: boolean) => - STATUSES_OF_KIND[kind] - .filter((status) => !isOpening || status !== closingStatus(kind)) - .map((status) => ({ value: status, label: INCIDENT_STATUS_LABEL[status] })) - -const IMPACT_ENTRIES = StatusPageIncidentImpact.literals.map((impact) => ({ - value: impact, - label: IMPACT_LABEL[impact], -})) - -const componentPicker = (h: H, model: Model): Html => { - const components = componentsOf(model) - return h.fieldset( - [h.Class("flex flex-col gap-1.5")], - [ - h.legend([h.Class("mb-1.5 text-xs text-muted-foreground")], ["Affected components"]), - components.length === 0 - ? h.p([h.Class("text-xs text-muted-foreground")], ["The page has no components yet."]) - : h.div( - [h.Class("grid grid-cols-2 gap-x-4 gap-y-2")], - components.map((component) => { - const inputId = `status-page-incident-component-${component.id}` - return h.label( - [ - h.Key(component.id), - h.Class("flex items-center gap-2 text-sm"), - h.Id(Checkbox.labelId(inputId)), - ], - [ - ...Checkbox.view(h, { - model: { - isChecked: model.incidentForm.componentIds.includes(component.id), - }, - toParentMessage: () => - Message.ToggledIncidentComponent({ componentId: component.id }), - id: `${inputId}-root`, - inputId, - }), - h.span([h.Class("truncate")], [component.name]), - ], - ) - }), - ), - ], - ) -} - -export const incidentDialog = (h: H, model: Model, timeZone: string): Html => { - const form = model.incidentForm - const isMaintenance = form.kind === "maintenance" - const issues = incidentIssues(form, timeZone) - return formDialog(h, { - slotId: "status-page-incident", - model: model.incidentDialog, - toParentMessage: (message) => Message.GotIncidentDialogMessage({ message }), - title: isMaintenance ? "Schedule maintenance" : "New incident", - description: isMaintenance - ? "Components show as under maintenance while it is in progress." - : "The first update is what visitors read. Post more as you learn more.", - onSubmit: Message.SubmittedIncident(), - submitLabel: form.isPublished ? (isMaintenance ? "Schedule" : "Publish incident") : "Save draft", - isPending: model.isCreatingIncident, - isSubmitDisabled: issues.length > 0, - footerStart: - issues.length === 0 - ? undefined - : h.p([h.Class("text-xs text-muted-foreground")], [issues[0] ?? ""]), - fields: [ - h.div( - [h.Class("flex flex-col gap-4")], - [ - textField(h, { - // Not `-title`: the dialog heading already uses that id. - id: "status-page-incident-title-field", - label: "Title", - value: form.title, - placeholder: isMaintenance ? "Database upgrade" : "Elevated API errors", - onInput: (value) => Message.ChangedIncidentField({ field: "title", value }), - }), - h.div( - [h.Class("grid gap-4 sm:grid-cols-2")], - [ - selectField(h, { - id: "status-page-incident-status", - label: "Status", - value: form.status, - entries: statusEntries(form.kind, true), - onInput: (value) => Message.ChangedIncidentField({ field: "status", value }), - }), - ...(isMaintenance - ? [] - : [ - selectField(h, { - id: "status-page-incident-impact", - label: "Impact", - value: form.impact, - entries: IMPACT_ENTRIES, - onInput: (value) => - Message.ChangedIncidentField({ field: "impact", value }), - }), - ]), - ], - ), - ...(isMaintenance - ? [ - h.div( - [h.Class("grid gap-4 sm:grid-cols-2")], - [ - textField(h, { - id: "status-page-incident-start", - label: "Starts", - type: "datetime-local", - value: form.scheduledStart, - onInput: (value) => - Message.ChangedIncidentField({ - field: "scheduledStart", - value, - }), - }), - textField(h, { - id: "status-page-incident-end", - label: "Ends", - type: "datetime-local", - value: form.scheduledEnd, - hint: `Times are in ${timeZone}.`, - onInput: (value) => - Message.ChangedIncidentField({ - field: "scheduledEnd", - value, - }), - }), - ], - ), - ] - : []), - componentPicker(h, model), - textareaField(h, { - id: "status-page-incident-body", - label: "Message", - value: form.body, - placeholder: isMaintenance - ? "We are upgrading the primary database. Expect brief read-only periods." - : "We are looking into elevated error rates on the API.", - onInput: (value) => Message.ChangedIncidentField({ field: "body", value }), - }), - h.div( - [h.Class("flex flex-col gap-1.5")], - [ - h.span([h.Class("text-xs text-muted-foreground")], ["Visibility"]), - segmentedSelect(h, { - ariaLabel: "Visibility", - size: "sm", - value: form.isPublished ? "publish" : "draft", - onChange: (value) => - Message.PickedIncidentPublished({ isPublished: value === "publish" }), - options: [ - { value: "publish", label: "Publish now" }, - { value: "draft", label: "Save as draft" }, - ], - }), - ], - ), - ...(model.incidentFailure === null - ? [] - : [ - alert(h, { variant: "crit", size: "sm" }, [ - icon(h, "CircleXmarkIcon"), - alertTitle(h, [ - isMaintenance ? "Couldn't schedule it" : "Couldn't open the incident", - ]), - alertDescription(h, [failureMessage(model.incidentFailure)]), - ]), - ]), - ], - ), - ], - }) -} - -export const updateDialog = (h: H, model: Model): Html => { - const form = model.updateForm - const incident = incidentsOf(model).find((row) => row.id === form?.incidentId) - return formDialog(h, { - slotId: "status-page-update", - model: model.updateDialog, - toParentMessage: (message) => Message.GotUpdateDialogMessage({ message }), - title: "Post an update", - description: incident?.title ?? "", - onSubmit: Message.SubmittedUpdate(), - submitLabel: "Post update", - isPending: model.isPostingUpdate, - isSubmitDisabled: form === null || form.body.trim() === "", - fields: - form === null - ? [] - : [ - h.div( - [h.Class("flex flex-col gap-4")], - [ - selectField(h, { - id: "status-page-update-status", - label: "Status", - value: form.status, - entries: statusEntries(form.kind, false), - hint: - form.status === closingStatus(form.kind) - ? "This closes it on the page." - : undefined, - onInput: (value) => - Message.ChangedUpdateField({ field: "status", value }), - }), - textareaField(h, { - id: "status-page-update-body", - label: "Message", - value: form.body, - onInput: (value) => Message.ChangedUpdateField({ field: "body", value }), - }), - ...(model.updateFailure === null - ? [] - : [ - alert(h, { variant: "crit", size: "sm" }, [ - icon(h, "CircleXmarkIcon"), - alertTitle(h, ["Couldn't post the update"]), - alertDescription(h, [failureMessage(model.updateFailure)]), - ]), - ]), - ], - ), - ], - }) -} diff --git a/apps/web/src/page/alerts/status-pages/editor/view.ts b/apps/web/src/page/alerts/status-pages/editor/view.ts deleted file mode 100644 index 1fd2e2a8cf..0000000000 --- a/apps/web/src/page/alerts/status-pages/editor/view.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { StatusPageComponentStatus } from "@maple/domain" -import type { V2StatusPage } from "@maple/domain/http/v2" -import { worstStatus } from "@maple/domain/status-page" -import { Option } from "effect" -import { AsyncData, Submodel } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { button } from "../../../../ui/button" -import type { LoadFailure } from "../../../../data/load-failure" -import { badge } from "../../../../ui/display" -import { errorState } from "../../../../ui/error-state" -import { icon } from "../../../../ui/icon" -import { skeleton } from "../../../../ui/skeleton" -import type { PageViewInputs } from "../../../contract" -import { dashboardPage } from "../../../kit/dashboard-page" -import { detailActions, detailTitle, detailTitleSkeleton } from "../../../kit/detail-header" -import { relativeTime } from "../../../kit/relative-time" -import { resourceNotFound } from "../../../kit/resource-not-found" -import { underlineTabClass, underlineTabStrip } from "../../../kit/underline-tabs" -import { - type EditorTab, - publicPath, - statusPageHref, - statusPagesTabHref, -} from "../../shared/status-page-commands" -import { - COMPONENT_STATUS_LABEL, - COMPONENT_STATUS_TONE, - componentStatus, - hasUnpublishedChanges, - isOpenIncident, -} from "../../shared/status-page-derive" -import { Message } from "./message" -import type { Model } from "./model" -import { componentsOf, incidentsOf, monitorsOf, pageOf } from "./reads" -import { resolveTab } from "./update" -import { componentDialog, componentsTab, deleteComponentDialog } from "./view-components" -import { incidentDialog, incidentsTab, updateDialog } from "./view-incidents" -import { jsonTab } from "./view-json" -import { breakGlassDialog, deletePageDialog, settingsTab } from "./view-settings" - -// `/alerts/status-pages/$pageId`: the header, the tabs, and the dialogs every tab opens. - -type H = HtmlBuilder - -const TABS: ReadonlyArray<{ value: EditorTab; label: string }> = [ - { value: "components", label: "Components" }, - { value: "incidents", label: "Incidents" }, - { value: "settings", label: "Settings" }, - { value: "json", label: "JSON" }, -] - -const tabBar = (h: H, page: V2StatusPage, active: EditorTab, openIncidents: number): Html => - underlineTabStrip( - h, - { label: "Status page sections", navigation: true }, - TABS.map((tab) => - h.a( - [ - h.Key(tab.value), - h.Href(statusPageHref(page.id, tab.value)), - ...(tab.value === active ? [h.AriaCurrent("page")] : []), - h.Class(underlineTabClass(tab.value === active)), - ], - [ - tab.label, - ...(tab.value === "incidents" && openIncidents > 0 - ? [ - h.span( - [h.Class("font-mono text-xs tabular-nums text-severity-warn")], - [String(openIncidents)], - ), - ] - : []), - ], - ), - ), - ) - -/** The banner a visitor sees now: the worst component, from what is loaded. */ -const overallOf = (model: Model): StatusPageComponentStatus => - worstStatus( - componentsOf(model).map((component) => - componentStatus(component, monitorsOf(model), incidentsOf(model)), - ), - ) - -/** When visitors last got the page, or why they did not. */ -const publishLine = (h: H, model: Model, page: V2StatusPage, timeZone: string): Html => { - const className = "text-xs text-muted-foreground" - if (page.last_publish_error !== null) - return h.span( - [h.Class("text-xs text-destructive")], - [`Last publish failed: ${page.last_publish_error}`], - ) - if (page.published_at === null) return h.span([h.Class(className)], ["Not published yet"]) - return h.span( - [h.Class(className)], - [ - relativeTime(h, { - value: page.published_at, - nowMs: model.nowMs ?? 0, - timeZone, - tooltip: "title", - prefix: "Published", - }), - ...(hasUnpublishedChanges(page) ? [" · changes go out within a minute"] : []), - ], - ) -} - -const headerActions = (h: H, model: Model): Html => - detailActions(h, [ - ...(model.isAdmin - ? [ - button( - h, - { - size: "sm", - variant: "outline", - isLoading: model.isPublishing, - attributes: [h.OnClick(Message.ClickedPublish())], - }, - [icon(h, "PaperPlaneIcon"), "Publish now"], - ), - ] - : []), - ]) - -/** The read a tab waits on: its failure, and whether its data is in. */ -const tabRead = (model: Model, tab: EditorTab): { failure: Option.Option; isReady: boolean } => { - switch (tab) { - case "settings": - return { failure: Option.none(), isReady: true } - // The document is its own read: the components list stops at a page of them. - case "json": - return { - failure: AsyncData.getError(model.config.data), - isReady: AsyncData.hasData(model.config.data), - } - // Incidents name components and the composer picks them, so a failed components read shows too. - case "incidents": - return { - failure: AsyncData.getError(model.incidents.data).pipe( - Option.orElse(() => AsyncData.getError(model.components.data)), - ), - isReady: AsyncData.hasData(model.incidents.data) && AsyncData.hasData(model.components.data), - } - // Statuses need monitors and incidents too, and the dialog's monitor picker needs monitors: - // a failure in either must show as one, not as every component Operational. - case "components": - return { - failure: AsyncData.getError(model.components.data).pipe( - Option.orElse(() => AsyncData.getError(model.monitors.data)), - Option.orElse(() => AsyncData.getError(model.incidents.data)), - ), - isReady: - AsyncData.hasData(model.components.data) && - AsyncData.hasData(model.monitors.data) && - AsyncData.hasData(model.incidents.data), - } - } -} - -const tabBody = (h: H, model: Model, page: V2StatusPage, tab: EditorTab, timeZone: string): Html => { - const read = tabRead(model, tab) - if (Option.isSome(read.failure)) - return errorState(h, { - failure: read.failure.value, - title: - tab === "incidents" - ? "Couldn't load incidents" - : tab === "json" - ? "Couldn't load the config" - : "Couldn't load components", - onRetry: Message.ClickedRetry(), - className: "py-12", - }) - if (!read.isReady) return skeleton(h, "h-48 w-full") - switch (tab) { - case "components": - return componentsTab(h, model) - case "incidents": - return incidentsTab(h, model, { nowMs: model.nowMs ?? 0, timeZone }) - case "settings": - return settingsTab(h, model, page) - case "json": - return jsonTab(h, model) - } -} - -const loaded = (h: H, model: Model, page: V2StatusPage, inputs: PageViewInputs): Html => { - const tab = resolveTab(model.search.tab) - const timeZone = inputs.context.timeZone - const overall = overallOf(model) - // The badge only claims a status once every input to it has loaded. - const hasComponents = - AsyncData.hasData(model.components.data) && - AsyncData.hasData(model.monitors.data) && - AsyncData.hasData(model.incidents.data) && - componentsOf(model).length > 0 - return h.div( - [h.Class("contents")], - [ - dashboardPage( - h, - { - titleContent: detailTitle(h, { - kind: ["Status page", publicPath(page.slug)], - title: [page.name], - adornment: hasComponents - ? badge(h, { variant: COMPONENT_STATUS_TONE[overall] }, [ - COMPONENT_STATUS_LABEL[overall], - ]) - : h.empty, - meta: [publishLine(h, model, page, timeZone)], - }), - actions: headerActions(h, model), - tabs: tabBar(h, page, tab, incidentsOf(model).filter(isOpenIncident).length), - gap: "md", - }, - [tabBody(h, model, page, tab, timeZone)], - ), - componentDialog(h, model), - deleteComponentDialog(h, model), - incidentDialog(h, model, timeZone), - updateDialog(h, model), - breakGlassDialog(h, model), - deletePageDialog(h, model, page), - ], - ) -} - -const notFound = (h: H, model: Model): Html => - dashboardPage(h, {}, [ - resourceNotFound(h, { - title: "Status page not found", - description: [ - "No status page has the id ", - h.code([h.Class("font-mono")], [model.rawPageId]), - ".", - ], - back: { href: statusPagesTabHref(), label: "Back to status pages" }, - }), - ]) - -export const view = Submodel.defineView((model, inputs, h) => { - if (model.pageId === null) return notFound(h, model) - const data = model.page.data - if (AsyncData.isFailure(data)) - return data.error.code === "status_page_not_found" - ? notFound(h, model) - : dashboardPage(h, {}, [ - errorState(h, { - failure: data.error, - title: "Couldn't load the status page", - onRetry: Message.ClickedRetry(), - }), - ]) - return Option.match(pageOf(model), { - onNone: () => - dashboardPage(h, { titleContent: detailTitleSkeleton(h) }, [ - skeleton(h, "h-9 w-1/2"), - skeleton(h, "h-48 w-full"), - ]), - onSome: (page) => loaded(h, model, page, inputs), - }) -}) - -/** The H1 names the page, so the trail stops at the tab. */ -export const breadcrumbsOf = () => [ - { label: "Alerts", href: "/alerts" }, - { label: "Status pages", href: statusPagesTabHref() }, -] diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index e1e3d0758f..b34e7d7e2f 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -25,7 +25,6 @@ import * as AnomaliesList from "./anomalies/list" import * as AnomalyDetail from "./anomalies/detail" import * as AlertIncident from "./alerts/incident" import * as AlertRule from "./alerts/rule" -import * as AlertsStatusPage from "./alerts/status-pages/editor" import * as AlertsUptimeMonitor from "./alerts/uptime-monitor" import * as AlertsUptimeNew from "./alerts/uptime-form" import * as IngestRuleEditor from "./settings/ingest-rule" @@ -88,6 +87,9 @@ import * as Share from "./share" import * as SourcesList from "./sources/list" import * as SourcesNew from "./sources/new" import * as SourceDetail from "./sources/detail" +import * as StatusPageBuilder from "./status-pages/builder" +import * as StatusPageEditor from "./status-pages/editor" +import * as StatusPagesList from "./status-pages/list" import * as TraceDetail from "./traces/detail" import * as TracesList from "./traces/list" @@ -112,7 +114,9 @@ export const pages = [ AlertRule.page, AlertsUptimeMonitor.page, AlertsUptimeNew.page, - AlertsStatusPage.page, + StatusPagesList.page, + StatusPageBuilder.page, + StatusPageEditor.page, AlertEditor.page, ServiceMap.page, InfraHosts.page, diff --git a/apps/web/src/page/alerts/status-pages/editor/command.ts b/apps/web/src/page/status-pages/editor/command.ts similarity index 72% rename from apps/web/src/page/alerts/status-pages/editor/command.ts rename to apps/web/src/page/status-pages/editor/command.ts index 211037ef7a..c06c3954a1 100644 --- a/apps/web/src/page/alerts/status-pages/editor/command.ts +++ b/apps/web/src/page/status-pages/editor/command.ts @@ -1,14 +1,16 @@ import { StatusPageComponentId, StatusPageId, StatusPageIncidentId } from "@maple/domain" import { StatusPageConfig, + StatusPageDraftComponent, + StatusPagePreviewParams, V2StatusPageComponentCreateParams, V2StatusPageIncidentCreateParams, V2StatusPageIncidentPostUpdateParams, V2StatusPageUpdateParams, } from "@maple/domain/http/v2" -import { Clock, Effect, Schema } from "effect" -import { Command } from "foldkit" -import { readIsAdmin } from "../../shared/commands" +import { Clock, Duration, Effect, Schema } from "effect" +import { Command, Dom } from "foldkit" +import { readIsAdmin } from "../../alerts/shared/commands" import { applyPageConfig, createIncident, @@ -17,6 +19,7 @@ import { listComponents, listPageIncidents, postIncidentUpdate, + previewPage, publishPage, reorderComponents, retrievePage, @@ -26,8 +29,9 @@ import { saveComponent, updateIncident, updatePage, -} from "../../shared/status-page-commands" -import { listMonitors } from "../../shared/uptime-commands" +} from "../shared/commands" +import { listMonitors, saveMonitor } from "../../alerts/shared/uptime-commands" +import { monitorPayloadFor } from "../shared/picker" import { Message } from "./message" export const ReadEditorClock = Command.define("ReadStatusPageClock", { @@ -215,3 +219,62 @@ export const DeletePage = Command.define("DeleteStatusPage", { Effect.map((result) => Message.CompletedDeletePage({ result })), ), }) + +/** How long the preview waits after the last change before it reads the monitors' history again. */ +const PREVIEW_DEBOUNCE_MS = 400 + +export const WaitEditorPreview = Command.define("WaitStatusPageEditorPreview", { + args: { generation: Schema.Number }, + messages: [Message.ElapsedPreviewDebounce], + execute: ({ generation }) => + Effect.as( + Effect.sleep(Duration.millis(PREVIEW_DEBOUNCE_MS)), + Message.ElapsedPreviewDebounce({ generation }), + ), +}) + +export const FetchEditorPreview = Command.define("FetchStatusPageEditorPreview", { + args: { generation: Schema.Number, draft: StatusPagePreviewParams }, + messages: [Message.CompletedFetchPreview], + execute: ({ generation, draft }) => + previewPage(draft).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchPreview({ generation, draft, result })), + ), +}) + +export const CreateEditorMonitor = Command.define("CreateStatusPageEditorMonitor", { + args: { url: Schema.String }, + messages: [Message.CompletedCreateMonitor], + execute: ({ url }) => + saveMonitor(null, monitorPayloadFor(url)).pipe( + Effect.result, + Effect.map((result) => Message.CompletedCreateMonitor({ result })), + ), +}) + +/** Adds the picked components in order, one create each; the first failure stops the rest. */ +export const AddComponents = Command.define("AddStatusPageComponents", { + args: { pageId: StatusPageId, components: Schema.Array(StatusPageDraftComponent) }, + messages: [Message.CompletedAddComponents], + execute: ({ pageId, components }) => + Effect.forEach(components, (component) => saveComponent(pageId, null, component)).pipe( + Effect.result, + Effect.map((result) => Message.CompletedAddComponents({ result })), + ), +}) + +export const FocusEditorElement = Command.define("FocusStatusPageEditorElement", { + args: { id: Schema.String }, + messages: [Message.CompletedFocus], + execute: ({ id }) => + Dom.focus(`#${CSS.escape(id)}`).pipe(Effect.ignore, Effect.as(Message.CompletedFocus())), +}) + +/** The public page in a new tab: it lives on the status Worker's host, outside the app. */ +export const OpenPublicPage = Command.define("OpenStatusPagePublicPage", { + args: { url: Schema.String }, + messages: [Message.OpenedPublicPage], + execute: ({ url }) => + Effect.sync(() => window.open(url, "_blank", "noopener")).pipe(Effect.as(Message.OpenedPublicPage())), +}) diff --git a/apps/web/src/page/alerts/status-pages/editor/config-json.test.ts b/apps/web/src/page/status-pages/editor/config-json.test.ts similarity index 91% rename from apps/web/src/page/alerts/status-pages/editor/config-json.test.ts rename to apps/web/src/page/status-pages/editor/config-json.test.ts index 76da608cc0..b3803d075b 100644 --- a/apps/web/src/page/alerts/status-pages/editor/config-json.test.ts +++ b/apps/web/src/page/status-pages/editor/config-json.test.ts @@ -1,6 +1,6 @@ import { Result } from "effect" import { describe, expect, it } from "vitest" -import { statusPage, statusPageConfig } from "../test-pages" +import { statusPage, statusPageConfig } from "../shared/test-pages" import { parseDraft, problemText } from "./config-json" const problemOf = (text: string) => diff --git a/apps/web/src/page/alerts/status-pages/editor/config-json.ts b/apps/web/src/page/status-pages/editor/config-json.ts similarity index 100% rename from apps/web/src/page/alerts/status-pages/editor/config-json.ts rename to apps/web/src/page/status-pages/editor/config-json.ts diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/status-pages/editor/editor.scene.test.ts similarity index 76% rename from apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts rename to apps/web/src/page/status-pages/editor/editor.scene.test.ts index 73032c5805..95749f810e 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/status-pages/editor/editor.scene.test.ts @@ -9,12 +9,12 @@ import { DEFAULT_STATUS_PAGE_THEME } from "@maple/domain/status-page" import { Option, Result, Schema } from "effect" import { attr, + Command, click, expect as expectView, find, given, inside, - Mount, role, scene, selector, @@ -24,17 +24,27 @@ import { withViewInputs, } from "foldkit/scene" import { describe, expect, test } from "vitest" -import { toLoadFailure } from "../../../../data/load-failure" -import * as Dialog from "../../../../ui/dialog" -import { PageOutMessage, type PageContext } from "../../../contract" -import { statusPagesTabHref } from "../../shared/status-page-commands" -import { ORG } from "../../shared/test-rows" -import { downMonitor, httpMonitor } from "../../shared/uptime-test-monitors" -import { component, componentId, incident, incidentId, statusPage, statusPageConfig } from "../test-pages" +import { toLoadFailure } from "../../../data/load-failure" +import { PageOutMessage, type PageContext } from "../../contract" +import { statusPagesHref } from "../shared/commands" +import { selectionOf } from "../shared/preview" +import { ORG } from "../../alerts/shared/test-rows" +import { downMonitor, httpMonitor } from "../../alerts/shared/uptime-test-monitors" +import { + component, + componentId, + incident, + incidentId, + statusPage, + statusPageConfig, +} from "../shared/test-pages" +import { PickerMessage } from "../shared/picker" +import { FocusEditorElement } from "./command" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update } from "./update" import { moved } from "./update-components" +import { canvasDocument } from "./view-canvas" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) @@ -61,9 +71,9 @@ type Fixture = Readonly<{ const loaded = (fixture: Fixture = {}): Model => { const entered = init( { - _tag: "AlertsStatusPage", + _tag: "StatusPage", pageId: PUBLIC_ID, - search: fixture.tab === undefined ? {} : { tab: fixture.tab }, + search: fixture.tab === undefined ? {} : { view: fixture.tab }, }, context, ).model @@ -110,11 +120,9 @@ const loaded = (fixture: Fixture = {}): Model => { ].reduce((model, message) => pageUpdate(model, message).model, entered) } -const mounted = () => Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()) - describe("status page editor", () => { test("entering reads the page, its components, incidents and the monitors", () => { - const entered = init({ _tag: "AlertsStatusPage", pageId: PUBLIC_ID, search: {} }, context) + const entered = init({ _tag: "StatusPage", pageId: PUBLIC_ID, search: {} }, context) const sent = names(entered.commands) expect(sent).toContain("FetchStatusPage") expect(sent).toContain("FetchStatusPageComponents") @@ -123,7 +131,7 @@ describe("status page editor", () => { }) test("fresh page and incident reads re-read the clock, so relative times never run ahead", () => { - const entered = init({ _tag: "AlertsStatusPage", pageId: PUBLIC_ID, search: {} }, context).model + const entered = init({ _tag: "StatusPage", pageId: PUBLIC_ID, search: {} }, context).model const afterPage = pageUpdate( entered, Message.CompletedFetchPage({ key: entered.page.key ?? "", result: Result.succeed(statusPage()) }), @@ -137,7 +145,7 @@ describe("status page editor", () => { }) test("an id that is not a page id shows not found without a read", () => { - const entered = init({ _tag: "AlertsStatusPage", pageId: "nope", search: {} }, context) + const entered = init({ _tag: "StatusPage", pageId: "nope", search: {} }, context) expect(names(entered.commands)).not.toContain("FetchStatusPage") scene( { update: pageUpdate, view }, @@ -152,7 +160,6 @@ describe("status page editor", () => { { update: pageUpdate, view }, given(model), expectView(role("heading", { name: /^Acme Status\s*Major outage$/u })).toExist(), - expectView(text("/s/acme-status")).toExist(), ) }) }) @@ -166,25 +173,18 @@ describe("read failures", () => { scene( { update: pageUpdate, view }, given(model), - expectView(text("Couldn't load components")).toExist(), + expectView(text("Couldn't load the page's components")).toExist(), expectView(role("heading", { name: /^Acme Status$/u })).toExist(), expectView(text("Operational")).not.toExist(), ) }) }) -describe("read failures on the incidents tab", () => { - test("a failed components read shows an error rather than incidents with no components", () => { - scene( - { update: pageUpdate, view }, - given(loaded({ tab: "incidents", componentsFail: true })), - expectView(text("Couldn't load incidents")).toExist(), - expectView(role("button", { name: "New incident" })).not.toExist(), - ) - }) -}) +describe("the page as the editor", () => { + const selected = (componentNumber: number, fixture: Fixture = {}) => + pageUpdate(loaded(fixture), Message.ClickedComponent({ componentId: componentId(componentNumber) })) + .model -describe("components tab", () => { test("moving a component down sends the whole new order once and shows it at once", () => { const moving = pageUpdate( loaded(), @@ -217,86 +217,188 @@ describe("components tab", () => { ) }) - test("adding a component links the picked monitor and sends the cleared fields as null", () => { - const monitor = downMonitor() - const opened = pageUpdate(loaded(), Message.ClickedAddComponent()).model - const filled = [ + test("a component picked in the On this page list opens in the panel with its fields", () => { + const target = component(2, { name: "API", group: "Core", manual_status: "degraded" }) + scene( + { update: pageUpdate, view }, + given(loaded({ components: [component(1), target] })), + click(role("button", { name: /^API/u })), + Command.resolve(FocusEditorElement, Message.CompletedFocus()), + expectView(role("textbox", { name: "Shown as" })).toHaveValue("API"), + expectView(role("textbox", { name: "Group" })).toHaveValue("Core"), + expectView(role("button", { name: /^API/u })).toHaveAttr("aria-pressed", "true"), + ) + }) + + test("a click in the preview selects the component or incident it names; unknown ids do nothing", () => { + const model = loaded({ incidents: [incident(1)] }) + const picked = pageUpdate( + model, + Message.ReceivedPreviewSelection({ kind: "component", id: componentId(3) }), + ) + expect(picked.model.panel).toEqual({ _tag: "Component", componentId: componentId(3) }) + expect(picked.model.componentForm.name).toBe("Component 3") + const incidentPicked = pageUpdate( + model, + Message.ReceivedPreviewSelection({ kind: "incident", id: incidentId(1) }), + ) + expect(incidentPicked.model.panel).toEqual({ _tag: "Incident", incidentId: incidentId(1) }) + expect( + pageUpdate(model, Message.ReceivedPreviewSelection({ kind: "component", id: "elsewhere" })).model + .panel, + ).toBeNull() + }) + + test("a preview message that is not exactly a selection is ignored", () => { + expect(selectionOf({ type: "select", kind: "component", id: componentId(1) })).toEqual( + Option.some({ kind: "component", id: componentId(1) }), + ) + expect(selectionOf({ type: "navigate", kind: "component", id: componentId(1) })).toEqual( + Option.none(), + ) + expect(selectionOf({ type: "select", kind: "page", id: componentId(1) })).toEqual(Option.none()) + expect(selectionOf({ type: "select", kind: "component", id: "