diff --git a/apps/ai/src/mcp/resources/instructions.ts b/apps/ai/src/mcp/resources/instructions.ts index 417486d4d4..3555fdb29a 100644 --- a/apps/ai/src/mcp/resources/instructions.ts +++ b/apps/ai/src/mcp/resources/instructions.ts @@ -116,7 +116,7 @@ first occurrences ──> (candidate) ──> issue in \`triage\` - Deploy verification: service_deployments (versions per service, newest vs previous) -> compare_periods around the rollout. Route usage over up to 90 days: route_usage. Silence check before calling an outage: ingest_freshness. Volume per service: ingest_usage. Database query shapes by volume: db_query_volume - Alert management: list_alert_rules / list_alert_destinations -> get_alert_rule -> preview_alert_rule (dry-run before saving; a window with no data is a blind rule, not a healthy one) -> create_alert_rule / update_alert_rule / delete_alert_rule -> list_alert_incidents - Uptime: list_uptime_monitors is every http/tcp/dns check and heartbeat with its status and last check; a failed check's trace id goes to inspect_trace. Admins manage them with create_uptime_monitor / update_uptime_monitor (enabled=false pauses) / delete_uptime_monitor. A down monitor opens an alert incident with signal type \`uptime\`; its rule is managed only through the monitor. -- Status pages (what customers see, separate from alert incidents): list_status_pages -> get_status_page (component statuses, open incidents, freshness) -> create_status_page_incident / schedule_status_page_maintenance (drafts unless publish=true) -> post_status_page_update -> resolve_status_page_incident. Every message is public text. +- Status pages (what customers see, separate from alert incidents): list_status_pages -> get_status_page (component statuses, open incidents, freshness) -> create_status_page_incident / schedule_status_page_maintenance (drafts unless publish=true) -> post_status_page_update -> resolve_status_page_incident. Every message is public text. Admins edit a whole page (settings, theme, components) with get_status_page_config -> apply_status_page_config, one atomic write. - AI agent sessions (LLM agent traces — NOT browser replays): list_agent_sessions -> get_agent_session (verdict and checks) -> get_agent_session_transcript (what it did step by step; \`failed_only\`, \`turn\`) -> inspect_span (one span in full) - Agent tool health: get_agent_tools_overview -> get_agent_tools_overview tool="" (its failure groups) -> get_agent_tool_error - Product analytics / conversion: list_product_events -> query_funnel (steps over page views, \`track()\` events and server events, stitched per person; \`breakdown_by\` a UTM/referrer dimension or an event attribute) -> add_dashboard_widget with \`panel_type: "funnel"\` and \`display_json.funnel.steps\` to pin it (\`display_json.funnel.variant: "dropoff"\` for the step-by-step drop-off view), or \`panel_type: "paths"\` with \`display_json.paths.anchor\` for what people do after / before one event diff --git a/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts index e219db30e4..eabecf3d6f 100644 --- a/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts @@ -1,6 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { Schema } from "effect" +import { RoleName } from "@maple/domain/http" import { + ApplyStatusPageConfigOutput, + GetStatusPageConfigOutput, GetStatusPageOutput, ListStatusPagesOutput, StatusPageIncidentMutationOutput, @@ -224,4 +227,176 @@ describe("status page tools", () => { ) expect((await freshness()).state).toBe("live") }) + + describe("config", () => { + const PAGE = "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1e01" + const admin = () => ({ + ...rt, + tenant: { ...rt.tenant, roles: [Schema.decodeUnknownSync(RoleName)("org:admin")] }, + }) + beforeAll(async () => { + const now = Date.now() + await executeSql( + rt.testDb, + `INSERT INTO status_pages (id, org_id, name, slug, created_by, created_at, updated_at) + VALUES ($1, $2, 'Config Page', 'config-page', $3, to_timestamp($4 / 1000.0), to_timestamp($4 / 1000.0))`, + [PAGE, rt.tenant.orgId, rt.tenant.userId, now], + ) + await executeSql( + rt.testDb, + `INSERT INTO status_page_components (id, org_id, page_id, name, position, created_at, updated_at) + VALUES ($1, $2, $3, 'Web', 0, now(), now()), ($4, $2, $3, 'Docs', 1, now(), now())`, + [ + "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1e02", + rt.tenant.orgId, + PAGE, + "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1e03", + ], + ) + }) + + it("reads the page as a JSON document with public ids, fenced for the model", async () => { + const result = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const { config } = result.structuredContent + expect(config).toMatchObject({ + name: "Config Page", + slug: "config-page", + components: [{ name: "Web" }, { name: "Docs" }], + }) + expect(config.components[0].id).toMatch(/^stc_/) + expect(decode(GetStatusPageConfigOutput, result).config.components).toHaveLength(2) + expect(markdown(result)).toContain("```json\n") + expect(markdown(result)).toContain(`"id": "${config.components[0].id}"`) + }) + + it("applies an edited document passed as a JSON string and summarises what changed", async () => { + const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const [web] = read.structuredContent.config.components + const edited = { + ...read.structuredContent.config, + description: "Live status.", + components: [{ name: "API" }, { ...web, group: "Apps" }], + } + const result = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: JSON.stringify(edited), + }) + expect(result.isError).not.toBe(true) + const output = decode(ApplyStatusPageConfigOutput, result) + expect(output.changes).toEqual({ + fields: ["description"], + created: ["API"], + updated: ["Web"], + deleted: ["Docs"], + reordered: false, + }) + expect(output.config.components.map((c) => c.name)).toEqual(["API", "Web"]) + const text = markdown(result) + expect(text).toContain("Components created") + expect(text).toContain("Docs") + + const again = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: result.structuredContent.config, + }) + expect(markdown(again)).toContain("Nothing changed") + }) + + it("reports nothing changed for a re-sent document that differs only until stored", async () => { + const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const { config } = read.structuredContent + const result = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: { + ...config, + name: ` ${config.name} `, + description: config.description === null ? " " : ` ${config.description} `, + components: config.components.map((c: { name: string }) => ({ + ...c, + name: ` ${c.name} `, + })), + }, + }) + expect(result.isError).not.toBe(true) + expect(decode(ApplyStatusPageConfigOutput, result).changes).toEqual({ + fields: [], + created: [], + updated: [], + deleted: [], + reordered: false, + }) + expect(markdown(result)).toContain("Nothing changed") + }) + + it("refuses a key the config does not define, as an object or as JSON text", async () => { + const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const { config } = read.structuredContent + const [first, ...rest] = config.components + const { show_uptime: _shown, ...withoutUptime } = first + const typo = { ...config, components: [{ ...withoutUptime, show_uptim: false }, ...rest] } + const asObject = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: typo, + }) + expect(asObject.isError).toBe(true) + expect(markdown(asObject)).toContain("show_uptim") + const asText = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: JSON.stringify({ ...config, colour: "red" }), + }) + expect(asText.isError).toBe(true) + expect(markdown(asText)).toContain("colour") + const after = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + expect(after.structuredContent.config).toEqual(config) + }) + + it("refuses a document read before the config changed, and says to read it again", async () => { + const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const { config } = read.structuredContent + await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: { ...config, description: "Changed meanwhile." }, + }) + const moved = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const stale = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: { ...config, components: [] }, + }) + expect(stale.isError).toBe(true) + expect(markdown(stale)).toContain("get_status_page_config again") + const explicit = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: moved.structuredContent.config, + if_version: config.version, + }) + expect(explicit.isError).toBe(true) + const after = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + expect(after.structuredContent.config).toEqual(moved.structuredContent.config) + expect(after.structuredContent.config.version).not.toBe(config.version) + }) + + it("refuses members and names a component id from another page", async () => { + const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const refused = await runToolDirect(rt, "apply_status_page_config", { + page: "config-page", + config: read.structuredContent.config, + }) + expect(refused.isError).toBe(true) + expect(markdown(refused)).toContain("only org admins") + + const other = await runToolDirect(rt, "get_status_page_config", { page: "acme-status" }) + const stolen = other.structuredContent.config.components[0].id + const foreign = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: { + ...read.structuredContent.config, + components: [{ id: stolen, name: "Stolen" }], + }, + }) + expect(foreign.isError).toBe(true) + expect(markdown(foreign)).toContain(stolen) + const after = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + expect(after.structuredContent.config).toEqual(read.structuredContent.config) + }) + }) }) diff --git a/apps/ai/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts index 91735d8320..492713619c 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -48,6 +48,7 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ "post_status_page_update", "resolve_status_page_incident", "schedule_status_page_maintenance", + "apply_status_page_config", // feedback "send_maple_feedback", ]) diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index 2e0331ada1..959a45eab7 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -1,7 +1,7 @@ // BOUNDARY: This module owns unparsed external values and narrows them before domain use. import { McpToolNotFoundError } from "@maple/domain/mcp-tool-contract" import type { McpToolSurface } from "@maple/domain/mcp-manifest" -import { Effect, Schema } from "effect" +import { Effect, Schema, type SchemaAST } from "effect" import { registerAddDashboardWidgetTool } from "./add-dashboard-widget" import { registerDescribeWarehouseTablesTool } from "./describe-warehouse-tables" import { registerComparePeriodsTool } from "./compare-periods" @@ -122,6 +122,8 @@ export interface MapleToolCatalogEntry { readonly title: string readonly description: string readonly schema: Schema.Codec + /** How arguments decode; `rejectUnknownKeys` tools refuse nested keys the schema lacks. */ + readonly parseOptions: SchemaAST.ParseOptions readonly outputSchema: Schema.Codec readonly hints: McpToolHints readonly aliases: Readonly> @@ -343,6 +345,7 @@ const collectMapleToolDefinitions = (): ReadonlyArray => { title: spec.title, description: spec.description, schema: spec.parameters, + parseOptions: { onExcessProperty: spec.rejectUnknownKeys === true ? "error" : "ignore" }, outputSchema: spec.output, hints: spec.hints, aliases: spec.aliases ?? {}, @@ -511,7 +514,9 @@ export const readToolCall = ( return { args: isArgumentRecord(normalized.args) ? normalized.args : {}, unknown: normalized.unknown.map((entry) => entry.key), - decodes: Schema.decodeUnknownOption(definition.schema)(normalized.args)._tag === "Some", + decodes: + Schema.decodeUnknownOption(definition.schema)(normalized.args, definition.parseOptions)._tag === + "Some", } } @@ -550,7 +555,7 @@ const isValidNextCall = (call: NextCall, surface: McpToolSurface): boolean => { const normalized = normalizeFor(target, call.args) return ( normalized.unknown.length === 0 && - Schema.decodeUnknownOption(target.schema)(normalized.args)._tag === "Some" + Schema.decodeUnknownOption(target.schema)(normalized.args, target.parseOptions)._tag === "Some" ) } @@ -619,7 +624,10 @@ export const executeRegisteredMcpToolUnscoped = Effect.fn("McpToolRegistry.execu ? undefined : { "maple.mcp.tool.aliased_arguments": normalized.renamed.map(([from]) => from).join(",") }), }) - const decoded = yield* Schema.decodeUnknownEffect(definition.schema)(normalized.args).pipe( + const decoded = yield* Schema.decodeUnknownEffect(definition.schema)( + normalized.args, + definition.parseOptions, + ).pipe( Effect.mapError( (error) => new McpDecodeError({ diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index 6a802daacf..a3e823e633 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -5,13 +5,21 @@ */ import { Clock, Effect, Schema } from "effect" import { + ApplyStatusPageConfigOutput, + GetStatusPageConfigOutput, GetStatusPageOutput, ListStatusPagesOutput, StatusPageIncidentMutationOutput, type StatusPageIncidentRow, type StatusPageRow, } from "@maple/domain/mcp-outputs" -import { decodePublicId, encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { + decodePublicId, + encodePublicId, + hasStatusPageConfigChanges, + PublicIdPrefixes, + StatusPageConfig, +} from "@maple/domain/http/v2" import type { StatusPageComponentRecord, StatusPageIncidentRecord, @@ -26,15 +34,20 @@ import { } from "@maple/domain/primitives" import { isActiveIncident, overallStatus, pageFreshness } from "@maple/domain/status-page" import { parseWarehouseDateTime, WarehouseTimeInput } from "@maple/query-engine" +import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" +import { auditAttribution } from "@maple/backend/services/audit/audit-access" +import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" +import { requireAdmin } from "@maple/backend/services/auth/auth" import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" import { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" -import { McpInvalidInputError, McpQueryError, type McpToolRegistrar } from "./types" +import { McpInvalidInputError, McpQueryError, McpUnavailableError, type McpToolRegistrar } from "./types" import { CurrentMcpTenant } from "../lib/query-warehouse" import * as P from "../lib/params" import { doc } from "../lib/tool-doc" const INCIDENT_STATUSES = ["investigating", "identified", "monitoring"] as const const MUTATION_HINTS = { readOnly: false, destructive: false, idempotent: false, openWorld: true } as const +const encodeConfig = Schema.encodeSync(StatusPageConfig) const iso = (ms: number) => new Date(ms).toISOString() const isoOrNull = (ms: number | null) => (ms === null ? null : iso(ms)) @@ -61,7 +74,13 @@ const toMcpError = parameter: "components", }) case "@maple/http/errors/StatusPageValidationError": + case "@maple/http/errors/StatusPageSlugTakenError": return new McpInvalidInputError({ message: error.message }) + case "@maple/http/errors/StatusPageConflictError": + return new McpInvalidInputError({ + message: `${error.message} Nothing was applied. Call get_status_page_config again and redo the edit on the fresh document; applying the old one would undo the other change.`, + parameter: "if_version", + }) default: return new McpQueryError({ message: `${error._tag}: ${error.message}`, pipeName: tool }) } @@ -574,4 +593,121 @@ export function registerStatusPageTools(server: McpToolRegistrar) { }), render: (output) => renderIncident("Maintenance scheduled", output), }) + + server.define({ + name: "get_status_page_config", + title: "Get Status Page Config", + description: + "One status page's settings, theme and ordered components as a JSON document (no incidents, break-glass token or publish state). Edit it and pass it to apply_status_page_config to change the page in one write. Same document as GET /v2/status_pages/{id}/config.", + parameters: Schema.Struct({ + page: P.text("The page's slug, name or id (list_status_pages lists them)"), + }), + output: GetStatusPageConfigOutput, + hints: { readOnly: true, destructive: false, idempotent: true }, + phrases: ["Reading a status page config"], + handler: Effect.fn("McpTool.getStatusPageConfig")(function* (params) { + const tool = "get_status_page_config" + const tenant = yield* CurrentMcpTenant + const service = yield* StatusPagesService + const page = yield* resolvePage(tool, tenant.orgId, params.page) + const config = yield* service + .getConfig(tenant.orgId, page.id) + .pipe(Effect.mapError(toMcpError(tool))) + return { page: toPageRow(page), config } + }), + render: (output) => ({ + title: `Status page config: ${output.page.name}`, + notices: [ + "apply_status_page_config replaces the page with an edited copy: keep a component's id to update it, drop the id to add one, leave it out to delete it. Order is display order. Keep the `version` key: the apply is refused if the config changed since this read.", + ], + blocks: [doc.code("json", JSON.stringify(encodeConfig(output.config), null, 2))], + }), + }) + + server.define({ + name: "apply_status_page_config", + title: "Apply Status Page Config", + description: + "Replace a status page with a config document from get_status_page_config, in one write that fully applies or not at all. Components with an `id` are updated, without one created, and left out deleted; list order is display order; omitted optional fields take their defaults; unknown keys are refused. The document's `version` guards the write: if the config changed since it was read, nothing is applied and you must read it again. Only org admins may call it, and the result is public on the page.", + parameters: Schema.Struct({ + page: P.text("The page's slug, name or id"), + config: P.json( + StatusPageConfig, + "The whole page document, as returned by get_status_page_config, edited. Keys it does not define are refused", + ), + if_version: P.optionalText( + "The config `version` the document was read with. Defaults to the document's `version`; the apply is refused if the config changed since", + ), + }), + rejectUnknownKeys: true, + output: ApplyStatusPageConfigOutput, + hints: { readOnly: false, destructive: true, idempotent: false, openWorld: true }, + phrases: ["Applying a status page config"], + handler: Effect.fn("McpTool.applyStatusPageConfig")(function* (params) { + const tool = "apply_status_page_config" + const tenant = yield* CurrentMcpTenant + yield* requireAdmin( + tenant.roles, + () => + new McpUnavailableError({ + message: `${tool} changes a public status page, which only org admins may do. Ask an admin, or edit it in Maple under Status pages.`, + capability: "org_admin", + }), + ) + const service = yield* StatusPagesService + const page = yield* resolvePage(tool, tenant.orgId, params.page) + const { config, changes } = yield* service + .applyConfig(tenant.orgId, page.id, params.config, tenant.userId, { + ifVersion: params.if_version ?? params.config.version, + }) + .pipe(Effect.mapError(toMcpError(tool))) + const audit = yield* AuditLogService + // Nothing written, nothing to audit. + if (hasStatusPageConfigChanges(changes)) + yield* audit.record({ + orgId: tenant.orgId, + ...auditAttribution(tenant, yield* CurrentAuditActor), + action: "status_page.config_applied", + resourceId: page.id, + metadata: { + name: config.name, + fields_changed: changes.fields.join(","), + components_created: changes.created.length, + components_updated: changes.updated.length, + components_deleted: changes.deleted.length, + components_reordered: changes.reordered, + }, + }) + const applied = yield* service.get(tenant.orgId, page.id).pipe(Effect.mapError(toMcpError(tool))) + return { page: toPageRow(applied), config, changes } + }), + render: (output) => { + const { fields, created, updated, deleted, reordered } = output.changes + const unchanged = + fields.length === 0 && + created.length === 0 && + updated.length === 0 && + deleted.length === 0 && + !reordered + const list = (names: ReadonlyArray) => (names.length === 0 ? undefined : names.join(", ")) + return { + title: `Status page config applied: ${output.page.name}`, + notices: unchanged + ? ["Nothing changed: the document matched the page."] + : ["The change publishes to the public page within a minute."], + blocks: [ + doc.fields([ + ["Fields changed", list(fields)], + ["Components created", list(created)], + ["Components updated", list(updated)], + ["Components deleted", list(deleted)], + ["Order changed", reordered ? "yes" : undefined], + ]), + ], + next: [ + doc.next("get_status_page", { page: output.page.slug }, "the page as visitors see it"), + ], + } + }, + }) } diff --git a/apps/ai/src/mcp/tools/types.ts b/apps/ai/src/mcp/tools/types.ts index b2edbed4a7..a921159542 100644 --- a/apps/ai/src/mcp/tools/types.ts +++ b/apps/ai/src/mcp/tools/types.ts @@ -184,6 +184,11 @@ export interface McpToolSpec< readonly phrases: McpToolPhrases /** Retired parameter names mapped to their current name. Accepted, never published. */ readonly aliases?: Readonly> + /** + * Refuse keys a nested object parameter does not declare, instead of dropping them. For + * documents that replace state, where a dropped typo would reset a field to its default. + */ + readonly rejectUnknownKeys?: true readonly handler: (params: P["Type"]) => Effect.Effect /** The model-facing text, derived from the output alone so the two cannot disagree. */ readonly render: (output: O["Type"]) => ToolDoc 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 8c598bf804..09069b5505 100644 --- a/apps/api/src/routes/v2/status-pages.http.test.ts +++ b/apps/api/src/routes/v2/status-pages.http.test.ts @@ -7,7 +7,7 @@ import { MapleApiV2 } from "@maple/domain/http/v2" import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/platform/test-pglite" import { Env } from "@maple/backend/platform/Env" import { ApiAuthorizationV2Live } from "@maple/backend/services/auth/ApiAuthorizationV2Live" -import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" +import { AuditLogService, makeMemoryAuditLog } from "@maple/backend/services/audit/AuditLogService" import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService" import { AuthService } from "@maple/backend/services/auth/AuthService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" @@ -17,7 +17,7 @@ import { MobileDevicesService } from "@maple/backend/services/push/MobileDevices import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, - AllV2GroupLayersLive, + allV2GroupLayersWithAudit, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, Phase1ResourceStubsLayer, @@ -54,6 +54,7 @@ const testConfig = () => const makeHarness = () => { const testDb = createTestDb(createdDbs) + const audit = makeMemoryAuditLog() const envLive = Env.layer.pipe(Layer.provide(testConfig())) const servicesLive = Layer.mergeAll( ApiKeysService.layer, @@ -65,7 +66,7 @@ const makeHarness = () => { ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) const routes = HttpApiBuilder.layer(MapleApiV2).pipe( - Layer.provide(AllV2GroupLayersLive), + Layer.provide(allV2GroupLayersWithAudit(Layer.succeed(AuditLogService, audit))), Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), @@ -73,7 +74,7 @@ const makeHarness = () => { Layer.provide(PlanetScaleServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Live), - Layer.provideMerge(AuditLogService.layerMemory), + Layer.provideMerge(Layer.succeed(AuditLogService, audit)), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) @@ -104,9 +105,13 @@ const makeHarness = () => { }), ) + const auditEntries = (action: string) => + Effect.runPromise(audit.list(ORG, { action, limit: 100, offset: 0 })) + return { request, bootstrapKey, + auditEntries, dispose: async () => { await disposeHandler() await runtime.dispose() @@ -465,4 +470,196 @@ describe("v2 status pages", () => { const intact = await harness.request("GET", incidentPath, token) expect(intact.body.status).toBe("investigating") })) + + describe("config", () => { + const seedPage = async (harness: ReturnType, token: string, slug: string) => { + const page = await harness.request("POST", "/v2/status_pages", token, { name: "Acme", slug }) + const base = `/v2/status_pages/${page.body.id}/components` + const api = await harness.request("POST", base, token, { name: "API" }) + const web = await harness.request("POST", base, token, { name: "Web", group: "Apps" }) + return { id: page.body.id as string, apiId: api.body.id as string, webId: web.body.id as string } + } + + it("round-trips the document without a revision bump", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id, apiId, webId } = await seedPage(harness, token, "acme-config") + const read = await harness.request("GET", `/v2/status_pages/${id}/config`, token) + expect(read.status).toBe(200) + expect(read.body).toMatchObject({ + name: "Acme", + slug: "acme-config", + show_powered_by: true, + auto_incidents: "off", + components: [ + { id: apiId, name: "API", group: null, monitor_id: null, show_uptime: true }, + { id: webId, name: "Web", group: "Apps" }, + ], + }) + const before = (await harness.request("GET", `/v2/status_pages/${id}`, token)).body.revision + expect(read.body.version).toMatch(/^[0-9a-z]+$/u) + + const applied = await harness.request( + "PUT", + `/v2/status_pages/${id}/config`, + token, + read.body, + ) + expect(applied.status).toBe(200) + expect(applied.body).toEqual(read.body) + const after = (await harness.request("GET", `/v2/status_pages/${id}`, token)).body.revision + // Nothing changed, so nothing republishes and nothing is audited. + expect(after).toBe(before) + expect(await harness.auditEntries("status_page.config_applied")).toEqual([]) + })) + + it("applies created, updated, deleted and reordered components with the page fields", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id, apiId, webId } = await seedPage(harness, token, "acme-apply") + const applied = await harness.request("PUT", `/v2/status_pages/${id}/config`, token, { + name: "Acme Inc", + slug: "acme-apply", + description: "Live status.", + theme: { preset: "midnight" }, + components: [ + { name: "Docs" }, + { id: apiId, name: "Public API", manual_status: "degraded" }, + ], + }) + expect(applied.status).toBe(200) + expect(applied.body).toMatchObject({ + name: "Acme Inc", + description: "Live status.", + theme: { preset: "midnight", appearance: "auto", accent: null }, + components: [ + { name: "Docs", manual_status: null }, + { id: apiId, name: "Public API", manual_status: "degraded" }, + ], + }) + expect(applied.body.components[0].id).toMatch(/^stc_/) + const listed = await harness.request("GET", `/v2/status_pages/${id}/components`, token) + expect( + listed.body.data.map((c: { id: string; position: number }) => [c.id, c.position]), + ).toEqual([ + [applied.body.components[0].id, 0], + [apiId, 1], + ]) + expect(listed.body.data.some((c: { id: string }) => c.id === webId)).toBe(false) + const [entry, ...rest] = await harness.auditEntries("status_page.config_applied") + expect(rest).toEqual([]) + expect(entry?.resourceId).toBe(id) + expect(entry?.metadata).toMatchObject({ + name: "Acme Inc", + components_created: 1, + components_updated: 1, + components_deleted: 1, + }) + })) + + it("refuses an invalid document with a 400 naming the problem and changes nothing", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id } = await seedPage(harness, token, "acme-invalid") + const other = await seedPage(harness, token, "acme-invalid-other") + const path = `/v2/status_pages/${id}/config` + const read = await harness.request("GET", path, token) + const foreign = await harness.request("PUT", path, token, { + ...read.body, + name: "Changed", + components: [...read.body.components, { id: other.apiId, name: "Stolen" }], + }) + expect(foreign.status).toBe(400) + expect(foreign.body.error.code).toBe("status_page_invalid") + expect(foreign.body.error.message).toContain(other.apiId) + const duplicate = await harness.request("PUT", path, token, { + ...read.body, + components: [...read.body.components, read.body.components[0]], + }) + expect(duplicate.status).toBe(400) + expect(duplicate.body.error.message).toContain("more than once") + const malformed = await harness.request("PUT", path, token, { name: "No slug" }) + expect(malformed.status).toBe(400) + expect((await harness.request("GET", path, token)).body).toEqual(read.body) + })) + + it("refuses a key the document does not define, naming it, and changes nothing", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id } = await seedPage(harness, token, "acme-typo") + const path = `/v2/status_pages/${id}/config` + const read = await harness.request("GET", path, token) + const [first, ...rest] = read.body.components + const { show_uptime: _shown, ...withoutUptime } = first + const typo = await harness.request("PUT", path, token, { + ...read.body, + name: "Changed", + components: [{ ...withoutUptime, show_uptim: false }, ...rest], + }) + expect(typo.status).toBe(400) + expect(typo.body.error.param).toBe("components[0].show_uptim") + const topLevel = await harness.request("PUT", path, token, { ...read.body, colour: "red" }) + expect(topLevel.status).toBe(400) + expect(topLevel.body.error.param).toBe("colour") + expect((await harness.request("GET", path, token)).body).toEqual(read.body) + })) + + it("with a stale if_version answers 409 and writes nothing; a current one applies", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id } = await seedPage(harness, token, "acme-stale") + const path = `/v2/status_pages/${id}/config` + const read = await harness.request("GET", path, token) + await harness.request("POST", `/v2/status_pages/${id}/components`, token, { name: "Added" }) + const moved = await harness.request("GET", path, token) + const stale = await harness.request("PUT", `${path}?if_version=${read.body.version}`, token, { + ...read.body, + name: "Stale", + }) + expect(stale.status).toBe(409) + expect(stale.body.error.code).toBe("status_page_conflict") + expect(stale.body.error.message).toContain("changed since it was read") + expect((await harness.request("GET", path, token)).body).toEqual(moved.body) + + const current = await harness.request( + "PUT", + `${path}?if_version=${moved.body.version}`, + token, + { ...moved.body, name: "Fresh" }, + ) + expect(current.status).toBe(200) + expect(current.body.name).toBe("Fresh") + expect(current.body.components.map((c: { name: string }) => c.name)).toContain("Added") + })) + + it("refuses more than 200 components", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id } = await seedPage(harness, token, "acme-big") + const path = `/v2/status_pages/${id}/config` + const read = await harness.request("GET", path, token) + const tooMany = await harness.request("PUT", path, token, { + ...read.body, + components: Array.from({ length: 201 }, (_, index) => ({ name: `C${index}` })), + }) + expect(tooMany.status).toBe(400) + expect((await harness.request("GET", path, token)).body).toEqual(read.body) + })) + + it("hides another org's config and refuses members", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { secret: outsider } = await harness.bootstrapKey(OTHER_ORG) + const { secret: member } = await harness.bootstrapKey(ORG, MEMBER) + const { id } = await seedPage(harness, token, "acme-guarded") + const path = `/v2/status_pages/${id}/config` + const read = await harness.request("GET", path, token) + expect((await harness.request("GET", path, outsider)).status).toBe(404) + expect((await harness.request("PUT", path, outsider, read.body)).status).toBe(404) + expect((await harness.request("GET", path, member)).status).toBe(200) + const refused = await harness.request("PUT", path, member, { ...read.body, name: "Member" }) + expect(refused.status).toBe(403) + expect((await harness.request("GET", path, token)).body.name).toBe("Acme") + })) + }) }) diff --git a/apps/api/src/routes/v2/status-pages.http.ts b/apps/api/src/routes/v2/status-pages.http.ts index 330f4bede2..8b6e83b765 100644 --- a/apps/api/src/routes/v2/status-pages.http.ts +++ b/apps/api/src/routes/v2/status-pages.http.ts @@ -18,6 +18,7 @@ import { type V2StatusPage, type V2StatusPageComponent, type V2StatusPageIncident, + hasStatusPageConfigChanges, } from "@maple/domain/http/v2" import type { OrgId, @@ -29,7 +30,7 @@ import type { } from "@maple/domain/primitives" import { DEFAULT_STATUS_PAGE_THEME, isActiveIncident } from "@maple/domain/status-page" import { Effect, Option } from "effect" -import { auditDiff } from "@/routes/v2/audit-changes" +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" @@ -334,6 +335,40 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag return toV2StatusPage(yield* service.get(tenant.orgId, params.id)) }), ) + .handle("getConfig", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + return yield* service.getConfig(tenant.orgId, params.id) + }), + ) + .handle("applyConfig", ({ params, query, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("update")) + const { previous, config, changes } = yield* service.applyConfig( + tenant.orgId, + params.id, + payload, + tenant.userId, + { ifVersion: query.if_version }, + ) + const { components: _before, version: _beforeVersion, ...pageBefore } = previous + const { components: _after, version: _afterVersion, ...pageAfter } = config + if (!hasStatusPageConfigChanges(changes)) return config + yield* recordHttpAudit("status_page.config_applied", { + resourceId: params.id, + changes: diffAuditChanges(pageBefore, pageAfter), + metadata: { + name: config.name, + components_created: changes.created.length, + components_updated: changes.updated.length, + components_deleted: changes.deleted.length, + components_reordered: changes.reordered, + }, + }) + return config + }), + ) .handle("listComponents", ({ params, query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/web/src/page/alerts/shared/status-page-commands.ts b/apps/web/src/page/alerts/shared/status-page-commands.ts index fcb0c184d7..8cb44f3acc 100644 --- a/apps/web/src/page/alerts/shared/status-page-commands.ts +++ b/apps/web/src/page/alerts/shared/status-page-commands.ts @@ -1,5 +1,6 @@ import { type StatusPageComponentId, StatusPageId, type StatusPageIncidentId } from "@maple/domain" import { + type StatusPageConfig, StatusPagePublicId, type V2StatusPageComponentCreateParams, type V2StatusPageCreateParams, @@ -149,6 +150,28 @@ export const reorderComponents = (id: StatusPageId, componentIds: ReadonlyArray< 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 @@ -177,7 +200,7 @@ const decodeInternalId = Schema.decodeUnknownOption(StatusPageId) export const pageIdFromUrl = (raw: string): Option.Option => Option.orElse(decodePublicId(raw), () => decodeInternalId(raw)) -export type EditorTab = "components" | "incidents" | "settings" +export type EditorTab = "components" | "incidents" | "settings" | "json" export const statusPageHref = (id: StatusPageId, tab?: EditorTab): string => hrefOf( diff --git a/apps/web/src/page/alerts/status-pages/editor/command.ts b/apps/web/src/page/alerts/status-pages/editor/command.ts index 6a0366b742..211037ef7a 100644 --- a/apps/web/src/page/alerts/status-pages/editor/command.ts +++ b/apps/web/src/page/alerts/status-pages/editor/command.ts @@ -1,5 +1,6 @@ import { StatusPageComponentId, StatusPageId, StatusPageIncidentId } from "@maple/domain" import { + StatusPageConfig, V2StatusPageComponentCreateParams, V2StatusPageIncidentCreateParams, V2StatusPageIncidentPostUpdateParams, @@ -9,6 +10,7 @@ import { Clock, Effect, Schema } from "effect" import { Command } from "foldkit" import { readIsAdmin } from "../../shared/commands" import { + applyPageConfig, createIncident, deleteComponent, deletePage, @@ -18,6 +20,7 @@ import { publishPage, reorderComponents, retrievePage, + retrievePageConfig, revokeBreakGlass, rotateBreakGlass, saveComponent, @@ -183,6 +186,26 @@ export const RevokeToken = Command.define("RevokeStatusPageBreakGlassToken", { ), }) +export const FetchConfig = Command.define("FetchStatusPageConfig", { + args: { key: Schema.String, pageId: StatusPageId }, + messages: [Message.CompletedFetchConfig], + execute: ({ key, pageId }) => + retrievePageConfig(pageId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchConfig({ key, result })), + ), +}) + +export const ApplyConfig = Command.define("ApplyStatusPageConfig", { + args: { pageId: StatusPageId, config: StatusPageConfig, ifVersion: Schema.UndefinedOr(Schema.String) }, + messages: [Message.CompletedApplyJson], + execute: ({ pageId, config, ifVersion }) => + applyPageConfig(pageId, config, ifVersion).pipe( + Effect.result, + Effect.map((result) => Message.CompletedApplyJson({ result })), + ), +}) + export const DeletePage = Command.define("DeleteStatusPage", { args: { pageId: StatusPageId }, messages: [Message.CompletedDeletePage], diff --git a/apps/web/src/page/alerts/status-pages/editor/config-json.test.ts b/apps/web/src/page/alerts/status-pages/editor/config-json.test.ts new file mode 100644 index 0000000000..76da608cc0 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/config-json.test.ts @@ -0,0 +1,21 @@ +import { Result } from "effect" +import { describe, expect, it } from "vitest" +import { statusPage, statusPageConfig } from "../test-pages" +import { parseDraft, problemText } from "./config-json" + +const problemOf = (text: string) => + Result.match(parseDraft(text, statusPageConfig(statusPage(), [])), { + onFailure: (problem) => problemText(problem), + onSuccess: () => "ok", + }) + +describe("parseDraft on hostile input", () => { + it("reports deep nesting as a problem instead of overflowing the stack", () => { + expect(problemOf("[".repeat(20_000))).toContain("Nested too deeply") + }) + + it("walks a very long array without overflowing the stack", () => { + const longArray = `[${Array.from({ length: 20_000 }, () => "1").join(",")}` + expect(problemOf(longArray)).toContain("column 40001") + }) +}) diff --git a/apps/web/src/page/alerts/status-pages/editor/config-json.ts b/apps/web/src/page/alerts/status-pages/editor/config-json.ts new file mode 100644 index 0000000000..496dfdf747 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/config-json.ts @@ -0,0 +1,182 @@ +import { + diffStatusPageConfig, + StatusPageComponentPublicId, + StatusPageConfig, + type StatusPageConfigChanges, +} from "@maple/domain/http/v2" +import { Result, Schema, SchemaIssue } from "effect" + +// The JSON tab's document: the page as `GET …/config` returns it, and a draft parsed back. + +const encodeConfig = Schema.encodeSync(StatusPageConfig) +const encodeId = Schema.encodeSync(StatusPageComponentPublicId) + +/** Pretty-printed with public ids, two-space indent; the version rides along as `if_version`. */ +export const formatConfig = (config: StatusPageConfig): string => { + const { version: _version, ...document } = config + return JSON.stringify(encodeConfig(document), null, 2) +} + +export type DraftProblem = + | { readonly _tag: "Syntax"; readonly message: string; readonly line: number; readonly column: number } + | { readonly _tag: "Schema"; readonly path: string; readonly message: string } + +export type DraftResult = Result.Result< + { readonly config: StatusPageConfig; readonly changes: StatusPageConfigChanges }, + DraftProblem +> + +// JSON SYNTAX +// Engines word (and locate) parse errors differently, so a small scanner finds the first one. + +const WHITESPACE = /[ \t\n\r]*/y +const STRING = /"(?:[^"\\\n\r\t]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"/y +const NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y +const LITERAL = /true|false|null/y + +type Scan = + | { readonly ok: true; readonly at: number } + | { readonly ok: false; readonly at: number; readonly message: string } + +const skip = (text: string, at: number): number => { + WHITESPACE.lastIndex = at + WHITESPACE.test(text) + return WHITESPACE.lastIndex +} + +const token = (pattern: RegExp, text: string, at: number): number | null => { + pattern.lastIndex = at + return pattern.test(text) ? pattern.lastIndex : null +} + +const fail = (at: number, message: string): Scan => ({ ok: false, at, message }) + +const scanString = (text: string, at: number): Scan => { + const end = token(STRING, text, at) + return end === null ? fail(at, "Unterminated or invalid string") : { ok: true, at: end } +} + +/** Deeper than any status page config; past it the scan reports a problem instead of overflowing the stack. */ +const MAX_DEPTH = 64 + +/** Members one after another in a loop, so a long array never deepens the stack. */ +const scanMembers = ( + text: string, + from: number, + close: "}" | "]", + member: (text: string, at: number) => Scan, +): Scan => { + let at = from + while (true) { + const scanned = member(text, skip(text, at)) + if (!scanned.ok) return scanned + const next = skip(text, scanned.at) + if (text[next] === close) return { ok: true, at: next + 1 } + if (text[next] !== ",") return fail(next, `Expected ',' or '${close}'`) + at = next + 1 + } +} + +const scanProperty = (text: string, at: number, depth: number): Scan => { + if (text[at] !== '"') return fail(at, "Expected a double-quoted property name") + const key = scanString(text, at) + if (!key.ok) return key + const colon = skip(text, key.at) + if (text[colon] !== ":") return fail(colon, "Expected ':' after the property name") + return scanValue(text, skip(text, colon + 1), depth) +} + +const scanValue = (text: string, at: number, depth: number): Scan => { + const char = text[at] + if ((char === "{" || char === "[") && depth >= MAX_DEPTH) return fail(at, "Nested too deeply") + if (char === "{") { + const inner = skip(text, at + 1) + return text[inner] === "}" + ? { ok: true, at: inner + 1 } + : scanMembers(text, inner, "}", (source, from) => scanProperty(source, from, depth + 1)) + } + if (char === "[") { + const inner = skip(text, at + 1) + return text[inner] === "]" + ? { ok: true, at: inner + 1 } + : scanMembers(text, inner, "]", (source, from) => scanValue(source, from, depth + 1)) + } + if (char === '"') return scanString(text, at) + const end = token(NUMBER, text, at) ?? token(LITERAL, text, at) + if (end !== null) return { ok: true, at: end } + return fail(at, at >= text.length ? "Unexpected end of the document" : "Expected a value") +} + +const locate = (text: string, at: number) => { + const before = text.slice(0, at).split("\n") + return { line: before.length, column: (before.at(-1)?.length ?? 0) + 1 } +} + +const syntaxProblem = (text: string): DraftProblem => { + const scanned = scanValue(text, skip(text, 0), 0) + const end = scanned.ok ? skip(text, scanned.at) : scanned.at + const message = scanned.ok ? "Unexpected text after the document" : scanned.message + return { _tag: "Syntax", message, ...locate(text, Math.min(end, text.length)) } +} + +// SCHEMA + +const formatIssue = SchemaIssue.makeFormatterStandardSchemaV1() + +type PathSegment = PropertyKey | { readonly key: PropertyKey } + +const pathOf = (path: ReadonlyArray): string => + path + .map((segment) => (typeof segment === "object" ? segment.key : segment)) + .reduce( + (out, key) => + typeof key === "number" + ? `${out}[${key}]` + : out === "" + ? String(key) + : `${out}.${String(key)}`, + "", + ) + +const parseJson = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Unknown)) +const decode = Schema.decodeUnknownResult(StatusPageConfig) + +/** A draft decoded against the schema, unknown keys refused, then the ids the apply would refuse. */ +export const parseDraft = (text: string, current: StatusPageConfig): DraftResult => + Result.mapError(parseJson(text), () => syntaxProblem(text)).pipe( + Result.flatMap((value) => + Result.mapError(decode(value, { onExcessProperty: "error" }), (error): DraftProblem => { + const [first] = formatIssue(error.issue).issues + return { + _tag: "Schema", + path: pathOf(first?.path ?? []), + message: first?.message ?? error.message, + } + }), + ), + Result.flatMap((config): DraftResult => { + const changes = diffStatusPageConfig(current, config) + const [unknown] = changes.unknownIds + if (unknown !== undefined) + return Result.fail({ + _tag: "Schema", + path: `components[${config.components.findIndex((c) => c.id === unknown)}].id`, + message: `${encodeId(unknown)} is not a component of this page. Remove the id to add a new one.`, + }) + const [duplicate] = changes.duplicateIds + if (duplicate !== undefined) + return Result.fail({ + _tag: "Schema", + path: `components[${config.components.map((c) => c.id).lastIndexOf(duplicate)}].id`, + message: `${encodeId(duplicate)} is listed more than once`, + }) + return Result.succeed({ config, changes }) + }), + ) + +export const problemText = (problem: DraftProblem): string => + problem._tag === "Syntax" + ? `Line ${problem.line}, column ${problem.column}: ${problem.message}` + : problem.path === "" + ? problem.message + : `${problem.path}: ${problem.message}` diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts index 2367a8b701..a028cdeb73 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -1,5 +1,10 @@ import { OrgId } from "@maple/domain" -import { StatusPagePublicId, type V2StatusPage } from "@maple/domain/http/v2" +import { + StatusPageComponentPublicId, + type StatusPageConfig, + StatusPagePublicId, + type V2StatusPage, +} from "@maple/domain/http/v2" import { DEFAULT_STATUS_PAGE_THEME } from "@maple/domain/status-page" import { Option, Result, Schema } from "effect" import { @@ -25,10 +30,10 @@ 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 } from "../test-pages" +import { component, componentId, incident, incidentId, statusPage, statusPageConfig } from "../test-pages" import { Message } from "./message" import type { Model } from "./model" -import { init, update } from "./update" +import { init, routeChanged, update } from "./update" import { moved } from "./update-components" import { view as pageView } from "./view" @@ -48,6 +53,9 @@ type Fixture = Readonly<{ incidents?: ReturnType[] monitorsFail?: boolean componentsFail?: boolean + /** The config read's document; by default the page and its components. */ + config?: StatusPageConfig + configFail?: boolean }> const loaded = (fixture: Fixture = {}): Model => { @@ -82,6 +90,23 @@ const loaded = (fixture: Fixture = {}): Model => { ? Result.fail(toLoadFailure(new Error("monitors unavailable"))) : Result.succeed([httpMonitor(), downMonitor()]), }), + // Only the JSON tab reads the config. + ...(entered.config.key === null + ? [] + : [ + Message.CompletedFetchConfig({ + key: entered.config.key, + result: fixture.configFail + ? Result.fail(toLoadFailure(new Error("config unavailable"))) + : Result.succeed( + fixture.config ?? + statusPageConfig( + fixture.page ?? statusPage(), + fixture.components ?? [component(1), component(2), component(3)], + ), + ), + }), + ]), ].reduce((model, message) => pageUpdate(model, message).model, entered) } @@ -538,3 +563,488 @@ describe("settings tab", () => { ) }) }) + +describe("json tab", () => { + const publicComponentId = (n: number) => Schema.encodeSync(StatusPageComponentPublicId)(componentId(n)) + /** The document the tab shows, as the user would edit it. */ + const documentOf = (components: ReadonlyArray) => ({ + name: "Acme Status", + slug: "acme-status", + components: components.map((n) => ({ id: publicComponentId(n), name: `Component ${n}` })), + }) + const editing = (draft: string, fixture: Fixture = {}) => + [Message.ClickedEditJson(), Message.ChangedJson({ value: draft })].reduce( + (model, message) => pageUpdate(model, message).model, + loaded({ tab: "json", ...fixture }), + ) + + test("shows the page as its config document, with public ids", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "json" })), + expectView(role("link", { name: "JSON" })).toHaveAttr("aria-current", "page"), + expectView(selector("pre")).toContainText('"slug": "acme-status"'), + expectView(selector("pre")).toContainText(`"id": "${publicComponentId(2)}"`), + expectView(selector("pre")).toContainText('"auto_incidents": "draft"'), + expectView(role("button", { name: "Copy config JSON" })).toExist(), + expectView(text("GET /v2/status_pages/{id}/config")).toExist(), + ) + }) + + test("members read the document but cannot edit it", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "json", isAdmin: false })), + expectView(selector("pre")).toContainText('"name": "Acme Status"'), + expectView(role("button", { name: "Edit JSON" })).not.toExist(), + expectView(role("textbox", { name: "Config JSON" })).not.toExist(), + ) + const tried = pageUpdate(loaded({ tab: "json", isAdmin: false }), Message.ClickedEditJson()) + expect(tried.model.jsonDraft).toBeNull() + }) + + test("Edit fills the editor with the current document, and Reset brings it back", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "json" })), + click(role("button", { name: "Edit JSON" })), + expectView(role("textbox", { name: "Config JSON" })).toExist(), + expectView(text("No changes yet.")).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + type(role("textbox", { name: "Config JSON" }), "{}"), + expectView(text("Not a valid page config")).toExist(), + click(role("button", { name: "Reset" })), + expectView(text("No changes yet.")).toExist(), + ) + }) + + test("invalid JSON shows where it breaks and disables Apply", () => { + scene( + { update: pageUpdate, view }, + given(editing('{\n "name": "Acme",\n "slug" "acme"\n}')), + expectView(text("Invalid JSON")).toExist(), + expectView(text("Line 3, column 10: Expected ':' after the property name")).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + }) + + test("a schema error names the path of the bad field", () => { + const draft = JSON.stringify({ ...documentOf([1]), components: [{ name: "" }] }) + scene( + { update: pageUpdate, view }, + given(editing(draft)), + expectView(text("Not a valid page config")).toExist(), + expectView(text(/^components\[0\]\.name: /u)).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + }) + + test("an id from elsewhere is refused before the write", () => { + const draft = JSON.stringify({ + ...documentOf([1]), + components: [{ id: publicComponentId(9), name: "X" }], + }) + scene( + { update: pageUpdate, view }, + given(editing(draft)), + expectView( + text( + `components[0].id: ${publicComponentId(9)} is not a component of this page. Remove the id to add a new one.`, + ), + ).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + }) + + test("the summary lists a created and a deleted component", () => { + const draft = JSON.stringify({ + ...documentOf([1, 2]), + components: [...documentOf([1, 2]).components, { name: "Status API" }], + }) + scene( + { update: pageUpdate, view }, + given(editing(draft)), + expectView(text("Components to create")).toExist(), + expectView(text("Status API")).toExist(), + expectView(text("Components to delete")).toExist(), + expectView(text("Component 3")).toExist(), + expectView(text("Fields changed")).toExist(), + expectView(role("button", { name: "Apply" })).toBeEnabled(), + ) + }) + + test("Apply sends one PUT with the decoded config, then re-reads the page and components", () => { + const draft = JSON.stringify({ ...documentOf([2, 1]), components: documentOf([2, 1]).components }) + const model = editing(draft) + const applying = pageUpdate(model, Message.ClickedApplyJson()) + expect(applying.commands).toMatchObject([ + { + name: "ApplyStatusPageConfig", + args: { + pageId: statusPage().id, + config: { + name: "Acme Status", + slug: "acme-status", + components: [ + { id: componentId(2), name: "Component 2" }, + { id: componentId(1), name: "Component 1" }, + ], + }, + }, + }, + ]) + expect(applying.commands).toHaveLength(1) + expect(pageUpdate(applying.model, Message.ClickedApplyJson()).commands ?? []).toEqual([]) + + const applied = pageUpdate( + applying.model, + Message.CompletedApplyJson({ + result: Result.succeed({ name: "Acme Status", slug: "acme-status", components: [] }), + }), + ) + expect(applied.model.jsonDraft).toBeNull() + expect(names(applied.commands)).toContain("FetchStatusPage") + expect(names(applied.commands)).toContain("FetchStatusPageComponents") + }) + + test("a refused apply shows the server's reason inline and keeps the draft", () => { + const reason = "Text needs a contrast of 4.5:1" + const model = editing(JSON.stringify(documentOf([1]))) + const applying = pageUpdate(model, Message.ClickedApplyJson()).model + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error(reason)), + message: reason, + retryable: false, + }), + }), + ).model + expect(refused.jsonDraft).toBe(model.jsonDraft) + scene( + { update: pageUpdate, view }, + given(refused), + expectView(text("Couldn't apply the config")).toExist(), + expectView(text(reason)).toExist(), + ) + }) + + test("the document comes from the config read, so components past the list's first page stay", () => { + const entered = init( + { _tag: "AlertsStatusPage", pageId: PUBLIC_ID, search: { tab: "json" } }, + context, + ) + expect(names(entered.commands)).toContain("FetchStatusPageConfig") + const listed = [component(1), component(2)] + const config = statusPageConfig(statusPage(), [...listed, component(3), component(4)]) + const model = loaded({ tab: "json", components: listed, config }) + scene( + { update: pageUpdate, view }, + given(model), + expectView(selector("pre")).toContainText(`"id": "${publicComponentId(4)}"`), + click(role("button", { name: "Edit JSON" })), + expectView(text("No changes yet.")).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + const dropped = JSON.stringify({ ...documentOf([1, 2, 3]) }) + const editingConfig = [Message.ClickedEditJson(), Message.ChangedJson({ value: dropped })].reduce( + (current, message) => pageUpdate(current, message).model, + model, + ) + scene( + { update: pageUpdate, view }, + given(editingConfig), + expectView(text("Components to delete")).toExist(), + expectView(text("Component 4")).toExist(), + ) + }) + + test("the tab shows the config read's loading and failure states", () => { + const entered = init( + { _tag: "AlertsStatusPage", pageId: PUBLIC_ID, search: { tab: "json" } }, + context, + ).model + const pageIn = pageUpdate( + entered, + Message.CompletedFetchPage({ key: entered.page.key ?? "", result: Result.succeed(statusPage()) }), + ).model + scene( + { update: pageUpdate, view }, + given(pageIn), + expectView(selector("pre")).not.toExist(), + expectView(role("button", { name: "Edit JSON" })).not.toExist(), + ) + const failed = loaded({ tab: "json", configFail: true }) + scene( + { update: pageUpdate, view }, + given(failed), + expectView(text("Couldn't load the config")).toExist(), + ) + expect(names(pageUpdate(failed, Message.ClickedRetry()).commands)).toContain("FetchStatusPageConfig") + }) + + test("opening the tab re-reads the config, since other tabs write the page too", () => { + const onSettings = loaded({ tab: "settings" }) + expect(onSettings.config.key).toBeNull() + const opened = routeChanged( + onSettings, + { _tag: "AlertsStatusPage", pageId: PUBLIC_ID, search: { tab: "json" } }, + context, + ) + expect(names(opened.commands)).toEqual(["FetchStatusPageConfig"]) + }) + + test("Apply sends the version the document was read with", () => { + const model = editing(JSON.stringify({ ...documentOf([1, 2]) }), { + config: { + ...statusPageConfig(statusPage(), [component(1), component(2), component(3)]), + version: "v7", + }, + }) + expect(pageUpdate(model, Message.ClickedApplyJson()).commands).toMatchObject([ + { name: "ApplyStatusPageConfig", args: { ifVersion: "v7" } }, + ]) + }) + + test("a newer config read while editing does not replace the version the draft was opened from", () => { + const base = { + ...statusPageConfig(statusPage(), [component(1), component(2), component(3)]), + version: "v7", + } + const draft = editing(JSON.stringify({ ...documentOf([1, 2]) }), { config: base }) + // Someone else's edit lands and the tab re-reads it, mid-edit. + const reread = pageUpdate( + draft, + Message.CompletedFetchConfig({ + key: draft.config.key ?? "", + result: Result.succeed({ ...base, version: "v8" }), + }), + ).model + expect(pageUpdate(reread, Message.ClickedApplyJson()).commands).toMatchObject([ + { name: "ApplyStatusPageConfig", args: { ifVersion: "v7" } }, + ]) + }) + + test("a draft that differs only by spacing or colour case is no change", () => { + const theme = { ...DEFAULT_STATUS_PAGE_THEME, appearance: "light" as const, accent: "#1a2b3c" } + const page = statusPage({ description: "Live status.", theme }) + const draft = JSON.stringify({ + ...documentOf([1, 2, 3]), + name: " Acme Status ", + description: " Live status. ", + auto_incidents: "draft", + theme: { ...theme, accent: "#1A2B3C" }, + components: documentOf([1, 2, 3]).components.map((c) => ({ + ...c, + name: ` ${c.name}`, + group: " ", + })), + }) + scene( + { update: pageUpdate, view }, + given(editing(draft, { page })), + expectView(text("No changes yet.")).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + }) + + test("while an apply runs, typing is ignored, the editor is disabled, and Settings cannot save", () => { + const draft = JSON.stringify(documentOf([1])) + const applying = pageUpdate(editing(draft), Message.ClickedApplyJson()).model + expect(applying.isApplyingJson).toBe(true) + expect(pageUpdate(applying, Message.ChangedJson({ value: "{}" })).model.jsonDraft).toBe(draft) + scene( + { update: pageUpdate, view }, + given(applying), + expectView(role("textbox", { name: "Config JSON" })).toBeDisabled(), + ) + const edited = pageUpdate( + applying, + Message.ChangedSetting({ field: "name", value: "Typed meanwhile" }), + ).model + expect(pageUpdate(edited, Message.ClickedSaveSettings()).commands ?? []).toEqual([]) + }) + + test("a successful apply keeps Settings edits typed meanwhile and re-reads incidents and the config", () => { + const draft = JSON.stringify(documentOf([1])) + const applying = pageUpdate(editing(draft), Message.ClickedApplyJson()).model + const typed = pageUpdate( + applying, + Message.ChangedSetting({ field: "name", value: "Typed meanwhile" }), + ).model + const applied = (model: Model) => + pageUpdate( + model, + Message.CompletedApplyJson({ + result: Result.succeed({ ...documentOf([1]), components: [], version: "v8" }), + }), + ) + const kept = applied(typed) + expect(kept.model.settingsEdits?.name).toBe("Typed meanwhile") + expect(names(kept.commands)).toEqual( + expect.arrayContaining(["FetchStatusPageIncidents", "FetchStatusPageConfig"]), + ) + expect(applied(applying).model.settingsEdits).toBeNull() + }) + + test("a conflict keeps the draft, says why and re-reads the config", () => { + const reason = "The page's config changed since it was read." + const applying = pageUpdate( + editing(JSON.stringify(documentOf([1]))), + Message.ClickedApplyJson(), + ).model + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error(reason)), + code: "status_page_conflict", + message: reason, + retryable: false, + }), + }), + ) + expect(refused.model.jsonDraft).toBe(applying.jsonDraft) + expect(names(refused.commands)).toEqual(["FetchStatusPageConfig"]) + scene({ update: pageUpdate, view }, given(refused.model), expectView(text(reason)).toExist()) + }) + + test("after a conflict the re-read becomes the draft's base, so the next Apply can go through", () => { + const base = { ...statusPageConfig(statusPage(), [component(1), component(2)]), version: "v7" } + const draftText = JSON.stringify(documentOf([1])) + const applying = pageUpdate(editing(draftText, { config: base }), Message.ClickedApplyJson()).model + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error("changed")), + code: "status_page_conflict", + message: "changed", + retryable: false, + }), + }), + ).model + const reread = pageUpdate( + refused, + Message.CompletedFetchConfig({ + key: refused.config.key ?? "", + result: Result.succeed({ ...base, version: "v8" }), + }), + ).model + expect(reread.jsonDraft).toBe(draftText) + scene({ update: pageUpdate, view }, given(reread), expectView(text("changed")).not.toExist()) + expect(pageUpdate(reread, Message.ClickedApplyJson()).commands).toMatchObject([ + { name: "ApplyStatusPageConfig", args: { ifVersion: "v8" } }, + ]) + }) + + test("cancelling after a conflict ends the rebase, so a later draft keeps its own base", () => { + const base = { ...statusPageConfig(statusPage(), [component(1), component(2)]), version: "v7" } + const applying = pageUpdate( + editing(JSON.stringify(documentOf([1])), { config: base }), + Message.ClickedApplyJson(), + ).model + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error("changed")), + code: "status_page_conflict", + message: "changed", + retryable: false, + }), + }), + ).model + // Cancel before the re-read lands, then start a fresh draft on v7. + const reopened = [Message.ClickedCancelJson(), Message.ClickedEditJson()].reduce( + (model, message) => pageUpdate(model, message).model, + refused, + ) + const reread = pageUpdate( + reopened, + Message.CompletedFetchConfig({ + key: reopened.config.key ?? "", + result: Result.succeed({ ...base, version: "v8" }), + }), + ).model + expect(reread.jsonBase?.version).toBe("v7") + }) + + test("a config read superseded by the conflict re-read does not rebase the draft", () => { + const base = { ...statusPageConfig(statusPage(), [component(1), component(2)]), version: "v7" } + const applying = pageUpdate( + editing(JSON.stringify(documentOf([1])), { config: base }), + Message.ClickedApplyJson(), + ).model + const staleKey = applying.config.key ?? "" + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error("changed")), + code: "status_page_conflict", + message: "changed", + retryable: false, + }), + }), + ).model + // The older read lands after the conflict started a new one: it must not become the base. + const stale = pageUpdate( + refused, + Message.CompletedFetchConfig({ + key: staleKey, + result: Result.succeed({ ...base, version: "v6" }), + }), + ).model + expect(stale.isRebasingJson).toBe(true) + expect(stale.jsonBase?.version).toBe("v7") + }) + + test("a failed re-read after a conflict leaves the rebase pending for the next good read", () => { + const base = { ...statusPageConfig(statusPage(), [component(1), component(2)]), version: "v7" } + const applying = pageUpdate( + editing(JSON.stringify(documentOf([1])), { config: base }), + Message.ClickedApplyJson(), + ).model + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error("changed")), + code: "status_page_conflict", + message: "changed", + retryable: false, + }), + }), + ).model + const failed = pageUpdate( + refused, + Message.CompletedFetchConfig({ + key: refused.config.key ?? "", + result: Result.fail(toLoadFailure(new Error("unreachable"))), + }), + ).model + expect(failed.isRebasingJson).toBe(true) + expect(failed.jsonBase?.version).toBe("v7") + }) + + test("unsaved Settings edits block Apply until they are saved or discarded", () => { + const draft = JSON.stringify(documentOf([1])) + const dirty = pageUpdate( + editing(draft), + Message.ChangedSetting({ field: "name", value: "Edited elsewhere" }), + ).model + scene( + { update: pageUpdate, view }, + given(dirty), + expectView(text("Unsaved settings")).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + expect(pageUpdate(dirty, Message.ClickedApplyJson()).commands ?? []).toEqual([]) + const discarded = pageUpdate(dirty, Message.ClickedDiscardSettings()).model + expect(names(pageUpdate(discarded, Message.ClickedApplyJson()).commands)).toContain( + "ApplyStatusPageConfig", + ) + }) +}) diff --git a/apps/web/src/page/alerts/status-pages/editor/message.ts b/apps/web/src/page/alerts/status-pages/editor/message.ts index f350e6db35..252ae92822 100644 --- a/apps/web/src/page/alerts/status-pages/editor/message.ts +++ b/apps/web/src/page/alerts/status-pages/editor/message.ts @@ -1,5 +1,6 @@ import { StatusPageComponentId, StatusPageIncidentId, StatusPageIncidentKind } from "@maple/domain" import { + StatusPageConfig, V2StatusPage, V2StatusPageBreakGlassToken, V2StatusPageComponent, @@ -89,5 +90,14 @@ export const Message = defineMessageUnion({ ClickedConfirmDeletePage: {}, CompletedDeletePage: { result: outcome(Schema.Void) }, GotDeletePageDialogMessage: { message: Dialog.Message }, + // JSON + CompletedFetchConfig: { key: Schema.String, result: outcome(StatusPageConfig) }, + ClickedEditJson: {}, + ChangedJson: { value: Schema.String }, + ClickedResetJson: {}, + ClickedCancelJson: {}, + ClickedApplyJson: {}, + CompletedApplyJson: { result: outcome(StatusPageConfig) }, + GotJsonCopyMessage: { message: CopyButton.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/status-pages/editor/model.ts b/apps/web/src/page/alerts/status-pages/editor/model.ts index c15f0589e1..6a52917a86 100644 --- a/apps/web/src/page/alerts/status-pages/editor/model.ts +++ b/apps/web/src/page/alerts/status-pages/editor/model.ts @@ -1,5 +1,6 @@ import { StatusPageComponentId, StatusPageId, StatusPageIncidentId } from "@maple/domain" import { + StatusPageConfig, V2StatusPage, V2StatusPageComponent, V2StatusPageIncident, @@ -72,5 +73,17 @@ export const Model = Schema.Struct({ tokenCopy: CopyButton.Model, deletePageDialog: Dialog.Model, isDeletingPage: Schema.Boolean, + // JSON + /** The page as `GET …/config` returns it: every component, and the revision to apply against. */ + config: KeyedSchema(StatusPageConfig, LoadFailure), + /** The edited document; `null` while the tab shows the page as saved. */ + jsonDraft: Schema.NullOr(Schema.String), + /** The saved document the draft was opened from: its diff base and the version an apply sends. */ + jsonBase: Schema.NullOr(StatusPageConfig), + /** After a conflict: the next config read becomes the draft's base, keeping the typed draft. */ + isRebasingJson: Schema.Boolean, + isApplyingJson: Schema.Boolean, + jsonFailure: Schema.NullOr(LoadFailure), + jsonCopy: CopyButton.Model, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/status-pages/editor/reads.ts b/apps/web/src/page/alerts/status-pages/editor/reads.ts index 84420f3a88..53a42ca291 100644 --- a/apps/web/src/page/alerts/status-pages/editor/reads.ts +++ b/apps/web/src/page/alerts/status-pages/editor/reads.ts @@ -6,7 +6,7 @@ import { requestKey } from "../../../../data/request-key" import * as Dialog from "../../../../ui/dialog" import { type PageContext, PageOutMessage, type PageReturn } from "../../../contract" import { UPTIME_LIST_LIMIT } from "../../shared/uptime-commands" -import { FetchComponents, FetchEditorMonitors, FetchIncidents, FetchPage } from "./command" +import { FetchComponents, FetchConfig, FetchEditorMonitors, FetchIncidents, FetchPage } from "./command" import { Message } from "./message" import type { Model } from "./model" @@ -18,6 +18,8 @@ const pageKey = (context: PageContext, pageId: string) => requestKey(context.orgId, "statusPages.retrieve", { pageId }) const componentsKey = (context: PageContext, pageId: string) => requestKey(context.orgId, "statusPages.listComponents", { pageId }) +const configKey = (context: PageContext, pageId: string) => + requestKey(context.orgId, "statusPages.getConfig", { pageId }) const incidentsKey = (context: PageContext, pageId: string) => requestKey(context.orgId, "statusPageIncidents.list", { pageId }) const monitorsKey = (context: PageContext) => @@ -65,6 +67,21 @@ export const readComponents = }) } +/** The JSON tab's document; read only while that tab is open, since it lists every component. */ +export const readConfig = + (context: PageContext, mode: Keyed.ReadMode) => + (model: Model): Return => { + const id = model.pageId + if (id === null) return { model } + return Option.match(Keyed.requestFor(model.config, configKey(context, id), mode), { + onNone: () => ({ model }), + onSome: (config) => ({ + model: { ...model, config }, + commands: [FetchConfig({ key: config.key, pageId: id })], + }), + }) + } + export const readIncidents = (context: PageContext, mode: Keyed.ReadMode) => (model: Model): Return => { diff --git a/apps/web/src/page/alerts/status-pages/editor/update-json.ts b/apps/web/src/page/alerts/status-pages/editor/update-json.ts new file mode 100644 index 0000000000..917607728d --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/update-json.ts @@ -0,0 +1,190 @@ +import { + hasStatusPageConfigChanges, + normalizeStatusPageConfig, + type StatusPageConfig, + type V2StatusPage, +} from "@maple/domain/http/v2" +import { Option, Result } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import type { LoadFailure } from "../../../../data/load-failure" +import * as CopyButton from "../../../../ui/copy-button" +import type { PageContext } from "../../../contract" +import { ApplyConfig } from "./command" +import { type DraftResult, formatConfig, parseDraft } from "./config-json" +import { isSameSettings, settingsFormOf } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { + chain, + pageOf, + readComponents, + readConfig, + readIncidents, + readPage, + type Return, + toast, +} from "./reads" +import { isSettingsDirty } from "./update-settings" + +// The JSON tab: the page as one document, and an admin's edit of it applied in one write. + +/** The saved page as `GET …/config` returned it, with every component and its version. */ +export const currentConfigOf = (model: Model): Option.Option => + AsyncData.getData(model.config.data) + +/** The draft parsed against the document it was opened from; none while not editing. */ +export const draftOf = (model: Model): Option.Option => + model.jsonDraft === null || model.jsonBase === null + ? Option.none() + : Option.some(parseDraft(model.jsonDraft, model.jsonBase)) + +const hasChanges = (draft: DraftResult): boolean => + Result.match(draft, { + onFailure: () => false, + onSuccess: ({ changes }) => hasStatusPageConfigChanges(changes), + }) + +/** Apply needs a valid draft that changes something, and no unsaved Settings edits to fight. */ +export const canApply = (model: Model): boolean => + model.isAdmin && + !model.isApplyingJson && + !model.isSavingSettings && + !isSettingsDirty(model) && + Option.match(draftOf(model), { onNone: () => false, onSome: hasChanges }) + +const withCurrent = (model: Model, write: (text: string, base: StatusPageConfig) => Model): Return => + Option.match(currentConfigOf(model), { + onNone: () => ({ model }), + onSome: (current) => ({ model: write(formatConfig(current), current) }), + }) + +/** Opens the editor on the saved document; Reset runs it again over the draft. */ +export const editJson = (model: Model): Return => + model.isAdmin + ? withCurrent(model, (jsonDraft, jsonBase) => ({ + ...model, + jsonDraft, + jsonBase, + isRebasingJson: false, + jsonFailure: null, + })) + : { model } + +/** Ignored while an apply runs: its result replaces the draft, and would drop the keystrokes. */ +export const changeJson = (model: Model, value: string): Return => + model.jsonDraft === null || model.isApplyingJson + ? { model } + : { model: { ...model, jsonDraft: value, jsonFailure: null } } + +export const cancelJson = (model: Model): Return => + model.isApplyingJson + ? { model } + : { model: { ...model, jsonDraft: null, jsonBase: null, isRebasingJson: false, jsonFailure: null } } + +export const applyJson = (model: Model): Return => { + const pageId = model.pageId + if (pageId === null || !canApply(model)) return { model } + // The version the draft was opened from, not the newest read: a re-read must not hide someone else's edit. + const ifVersion = model.jsonBase?.version + return Option.match(draftOf(model), { + onNone: () => ({ model }), + onSome: (draft) => + Result.match(draft, { + onFailure: () => ({ model }), + onSuccess: ({ config }) => ({ + model: { ...model, isApplyingJson: true, jsonFailure: null }, + commands: [ApplyConfig({ pageId, config, ifVersion })], + }), + }), + }) +} + +/** The loaded page with the applied document's settings, to compare Settings edits against. */ +const pageWith = (page: V2StatusPage, applied: StatusPageConfig): V2StatusPage => { + const config = normalizeStatusPageConfig(applied) + return { + ...page, + name: config.name, + slug: config.slug, + description: config.description, + website_url: config.website_url, + logo_url: config.logo_url, + show_powered_by: config.show_powered_by, + auto_incidents: config.auto_incidents, + theme: config.theme, + } +} + +/** + * Settings edits survive an apply when they are real edits: ones that match neither the page the + * apply replaced nor the one it wrote were typed meanwhile, and are kept. + */ +const settingsAfterApply = (model: Model, applied: StatusPageConfig): Model["settingsEdits"] => { + const edits = model.settingsEdits + if (edits === null || !isSettingsDirty(model)) return null + return Option.match(pageOf(model), { + onNone: () => edits, + onSome: (page) => (isSameSettings(edits, settingsFormOf(pageWith(page, applied))) ? null : edits), + }) +} + +export const appliedJson = ( + model: Model, + result: Result.Result, + context: PageContext, +): Return => + Result.match(result, { + onSuccess: (applied): Return => ({ + // Deleted components take their incident links with them, so incidents re-read too. + ...chain( + { + ...model, + isApplyingJson: false, + jsonDraft: null, + jsonBase: null, + isRebasingJson: false, + settingsEdits: settingsAfterApply(model, applied), + }, + readPage(context, "Supersede"), + readComponents(context, "Supersede"), + readIncidents(context, "Supersede"), + readConfig(context, "Supersede"), + ), + outMessage: toast("success", "Config applied", "It goes out with the next publish."), + }), + // A conflict means the page moved on: re-read it and rebase the draft onto it, keeping the text. + onFailure: (jsonFailure): Return => + jsonFailure.code === "status_page_conflict" + ? chain( + { ...model, isApplyingJson: false, isRebasingJson: true, jsonFailure }, + readConfig(context, "Supersede"), + ) + : { model: { ...model, isApplyingJson: false, jsonFailure } }, + }) + +/** + * Once the re-read after a conflict lands, the draft is checked against the current page: the + * summary shows what the next Apply would overwrite, and that Apply carries the current version. + */ +export const rebaseJsonDraft = (model: Model): Model => + model.isRebasingJson && model.jsonDraft !== null + ? Option.match(currentConfigOf(model), { + onNone: () => model, + onSome: (current) => ({ + ...model, + jsonBase: current, + isRebasingJson: false, + jsonFailure: null, + }), + }) + : model + +export const updateJsonCopy = (model: Model, message: CopyButton.Message): Return => { + const step = CopyButton.update(model.jsonCopy, message) + return { + model: { ...model, jsonCopy: step.model }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (child) => + Message.GotJsonCopyMessage({ message: child }), + ), + } +} diff --git a/apps/web/src/page/alerts/status-pages/editor/update-settings.ts b/apps/web/src/page/alerts/status-pages/editor/update-settings.ts index 808a193e01..a61a5bf51a 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update-settings.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update-settings.ts @@ -71,6 +71,7 @@ export const saveSettings = (model: Model): Return => { return model.pageId === null || edits === null || model.isSavingSettings || + model.isApplyingJson || !model.isAdmin || hasSettingsIssues(edits) ? { model } diff --git a/apps/web/src/page/alerts/status-pages/editor/update.ts b/apps/web/src/page/alerts/status-pages/editor/update.ts index 6da1a98568..ee549aa90c 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update.ts @@ -1,4 +1,4 @@ -import { Option } from "effect" +import { Option, Result } from "effect" import * as Keyed from "../../../../data/keyed" import type { AppRoute } from "../../../../route" import * as CopyButton from "../../../../ui/copy-button" @@ -9,7 +9,15 @@ import { FetchEditorSession, ReadEditorClock } from "./command" import { emptyComponentForm, emptyIncidentForm } from "./form" import { Message } from "./message" import type { Model } from "./model" -import { chain, readComponents, readIncidents, readMonitors, readPage, type Return } from "./reads" +import { + chain, + readComponents, + readConfig, + readIncidents, + readMonitors, + readPage, + type Return, +} from "./reads" import { askDeleteComponent, changeComponent, @@ -56,12 +64,21 @@ import { changeTheme, updateTokenCopy, } from "./update-settings" +import { + appliedJson, + applyJson, + cancelJson, + changeJson, + editJson, + rebaseJsonDraft, + updateJsonCopy, +} from "./update-json" type EditorRoute = Extract /** Unknown `tab` values open Components, so an old or mistyped link still lands. */ export const resolveTab = (tab: string | undefined): EditorTab => - tab === "incidents" || tab === "settings" ? tab : "components" + tab === "incidents" || tab === "settings" || tab === "json" ? tab : "components" export const init = (route: EditorRoute, context: PageContext): Return => { const model: Model = { @@ -72,6 +89,7 @@ export const init = (route: EditorRoute, context: PageContext): Return => { page: Keyed.idle(), components: Keyed.idle(), incidents: Keyed.idle(), + config: Keyed.idle(), monitors: Keyed.idle(), isAdmin: false, isSessionLoaded: false, @@ -104,6 +122,12 @@ export const init = (route: EditorRoute, context: PageContext): Return => { tokenCopy: CopyButton.init(), deletePageDialog: Dialog.init("status-page-delete", "alert-dialog"), isDeletingPage: false, + jsonDraft: null, + jsonBase: null, + isRebasingJson: false, + isApplyingJson: false, + jsonFailure: null, + jsonCopy: CopyButton.init(), } const reads = chain( model, @@ -111,6 +135,7 @@ export const init = (route: EditorRoute, context: PageContext): Return => { readComponents(context, "Load"), readIncidents(context, "Load"), readMonitors(context, "Load"), + whenJson(context, "Load"), ) return { model: reads.model, @@ -122,10 +147,15 @@ export const init = (route: EditorRoute, context: PageContext): Return => { } } -/** A tab switch is a search change: the reads are already in the Model. */ -export const routeChanged = (model: Model, route: EditorRoute): Return => ({ - model: { ...model, search: route.search }, -}) +/** The config read, only while the JSON tab is open: the document must be current when edited. */ +const whenJson = + (context: PageContext, mode: Keyed.ReadMode) => + (model: Model): Return => + resolveTab(model.search.tab) === "json" ? readConfig(context, mode)(model) : { model } + +/** A tab switch is a search change; opening JSON re-reads the config, since other tabs write too. */ +export const routeChanged = (model: Model, route: EditorRoute, context: PageContext): Return => + whenJson(context, "Refresh")({ ...model, search: route.search }) /** The dialogs ignore dismissal while their write is in flight, as `ConfirmDialog` does. */ const whenIdle = (isBusy: boolean, model: Model, run: () => Return): Return => (isBusy ? { model } : run()) @@ -145,6 +175,14 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, incidents: Keyed.accept(model.incidents, key, result) }, commands: [ReadEditorClock()], }), + CompletedFetchConfig: ({ key, result }) => { + const next = { ...model, config: Keyed.accept(model.config, key, result) } + // Only a successful read the slot kept may rebase the draft; a superseded or failed one + // carries older data, and the rebase waits for the next good read. + return { + model: key === model.config.key && Result.isSuccess(result) ? rebaseJsonDraft(next) : next, + } + }, CompletedFetchMonitors: ({ key, result }) => ({ model: { ...model, monitors: Keyed.accept(model.monitors, key, result) }, }), @@ -157,6 +195,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re readComponents(context, "Supersede"), readIncidents(context, "Supersede"), readMonitors(context, "Supersede"), + whenJson(context, "Supersede"), ), // Components ClickedAddComponent: () => openComponent(model, null), @@ -240,4 +279,12 @@ export const update = (model: Model, message: Message, context: PageContext): Re whenIdle(model.isDeletingPage, model, () => deletePageDialog(model, Dialog.update(model.deletePageDialog, child)), ), + // JSON + ClickedEditJson: () => editJson(model), + ChangedJson: ({ value }) => changeJson(model, value), + ClickedResetJson: () => editJson(model), + ClickedCancelJson: () => cancelJson(model), + ClickedApplyJson: () => applyJson(model), + CompletedApplyJson: ({ result }) => appliedJson(model, result, context), + GotJsonCopyMessage: ({ message: child }) => updateJsonCopy(model, child), }) diff --git a/apps/web/src/page/alerts/status-pages/editor/view-json.ts b/apps/web/src/page/alerts/status-pages/editor/view-json.ts new file mode 100644 index 0000000000..e33440f630 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/view-json.ts @@ -0,0 +1,242 @@ +import type { StatusPageConfigChanges } from "@maple/domain/http/v2" +import { Option, Result } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "../../../../data/load-failure" +import { alert, alertDescription, alertTitle } from "../../../../ui/alert" +import { button } from "../../../../ui/button" +import * as CopyButton from "../../../../ui/copy-button" +import { icon } from "../../../../ui/icon" +import { label } from "../../../../ui/label" +import { panel, panelBody, panelHeader } from "../../../../ui/panel" +import { textarea } from "../../../../ui/textarea" +import { adminOnlyNote } from "../../../settings/kit/section" +import { formatConfig, problemText } from "./config-json" +import { Message } from "./message" +import type { Model } from "./model" +import { canApply, currentConfigOf, draftOf } from "./update-json" +import { isSettingsDirty } from "./update-settings" + +// The JSON tab: the page as one document to copy, and for admins an editor that applies it. + +type H = HtmlBuilder + +const code = (h: H, text: string): Html => h.code([h.Class("font-mono text-xs")], [text]) + +const intro = (h: H): Html => + h.p( + [h.Class("font-prose text-sm text-muted-foreground")], + [ + "The page's settings, theme and components as one document, without incidents. Same document as ", + code(h, "GET /v2/status_pages/{id}/config"), + " and the MCP tool ", + code(h, "get_status_page_config"), + ". Applying it replaces the page: a component without an ", + code(h, "id"), + " is added, one left out is deleted, and list order is display order.", + ], + ) + +const changeRows = (changes: StatusPageConfigChanges): ReadonlyArray => [ + ...(changes.fields.length === 0 ? [] : [["Fields changed", changes.fields.join(", ")] as const]), + ...(changes.created.length === 0 ? [] : [["Components to create", changes.created.join(", ")] as const]), + ...(changes.updated.length === 0 ? [] : [["Components to update", changes.updated.join(", ")] as const]), + ...(changes.deleted.length === 0 ? [] : [["Components to delete", changes.deleted.join(", ")] as const]), + ...(changes.reordered ? [["Order", "Components change order"] as const] : []), +] + +const summary = (h: H, changes: StatusPageConfigChanges): Html => { + const rows = changeRows(changes) + return panel(h, {}, [ + panelHeader(h, { title: "Changes" }), + panelBody( + h, + [ + rows.length === 0 + ? h.p([h.Class("text-sm text-muted-foreground")], ["No changes yet."]) + : h.dl( + [h.Class("grid grid-cols-[max-content_minmax(0,1fr)] gap-x-6 gap-y-1.5 text-sm")], + rows.flatMap(([term, detail]) => [ + h.dt([h.Class("text-muted-foreground")], [term]), + h.dd([h.Class("text-foreground")], [detail]), + ]), + ), + ], + "p-4", + ), + ]) +} + +const readView = (h: H, model: Model, text: string): Html => + panel(h, {}, [ + panelHeader(h, { + title: "status-page.json", + action: h.div( + [h.Class("flex items-center gap-2")], + [ + CopyButton.view(h, { + model: model.jsonCopy, + toParentMessage: (message) => Message.GotJsonCopyMessage({ message }), + value: text, + label: "config JSON", + idleLabel: "Copy", + variant: "outline", + size: "sm", + }), + ...(model.isAdmin + ? [ + button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedEditJson())], + }, + [icon(h, "PencilIcon"), "Edit JSON"], + ), + ] + : []), + ], + ), + }), + h.pre( + [ + h.AriaLabel("Status page config"), + h.Class("max-h-[70vh] overflow-auto p-4 font-mono text-xs leading-relaxed text-foreground"), + ], + [text], + ), + ]) + +const editView = (h: H, model: Model, draft: string): Html => { + const parsed = draftOf(model) + const problem = Option.flatMap(parsed, (result) => Result.getFailure(result)) + const changes = Option.flatMap(parsed, (result) => + Option.map(Result.getSuccess(result), (value) => value.changes), + ) + const isBlocked = isSettingsDirty(model) + return h.div( + [h.Class("flex flex-col gap-4")], + [ + ...(isBlocked + ? [ + alert(h, { variant: "warn", size: "sm" }, [ + icon(h, "CircleWarningIcon"), + alertTitle(h, ["Unsaved settings"]), + alertDescription(h, [ + "The Settings tab has unsaved edits. Save or discard them before applying JSON.", + ]), + ]), + ] + : []), + h.div( + [h.Class("flex flex-col gap-1.5")], + [ + label( + h, + { + className: "text-xs text-muted-foreground", + attributes: [h.For("status-page-json")], + }, + ["Config JSON"], + ), + textarea(h, { + id: "status-page-json", + isDisabled: model.isApplyingJson, + controlClassName: "min-h-[28rem] font-mono text-xs leading-relaxed", + attributes: [ + h.Value(draft), + h.Rows(28), + h.Spellcheck(false), + h.Attribute("autocapitalize", "off"), + h.OnInput((value) => Message.ChangedJson({ value })), + ...(Option.isSome(problem) + ? [h.AriaInvalid(true), h.AriaDescribedBy("status-page-json-error")] + : []), + ], + }), + ], + ), + Option.match(problem, { + onSome: (value) => + h.div( + [h.Id("status-page-json-error")], + [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [ + value._tag === "Syntax" ? "Invalid JSON" : "Not a valid page config", + ]), + alertDescription(h, [ + h.span([h.Class("font-mono text-xs")], [problemText(value)]), + ]), + ]), + ], + ), + onNone: () => + Option.match(changes, { onSome: (value) => summary(h, value), onNone: () => h.empty }), + }), + ...(model.jsonFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't apply the config"]), + alertDescription(h, [failureMessage(model.jsonFailure)]), + ]), + ]), + h.div( + [h.Class("flex items-center justify-end gap-2")], + [ + button( + h, + { + size: "sm", + variant: "ghost", + isDisabled: model.isApplyingJson, + attributes: [h.OnClick(Message.ClickedResetJson())], + }, + ["Reset"], + ), + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: model.isApplyingJson, + attributes: [h.OnClick(Message.ClickedCancelJson())], + }, + ["Cancel"], + ), + button( + h, + { + size: "sm", + isLoading: model.isApplyingJson, + isDisabled: !canApply(model), + attributes: [h.OnClick(Message.ClickedApplyJson())], + }, + ["Apply"], + ), + ], + ), + ], + ) +} + +export const jsonTab = (h: H, model: Model): Html => + Option.match(currentConfigOf(model), { + onNone: () => h.empty, + onSome: (current) => + h.div( + [h.Class("flex max-w-4xl flex-col gap-4")], + [ + intro(h), + ...(model.isAdmin + ? [] + : [adminOnlyNote(h, ["Only org admins can apply a config. You can copy it."])]), + model.isAdmin && model.jsonDraft !== null + ? editView(h, model, model.jsonDraft) + : readView(h, model, formatConfig(current)), + ], + ), + }) diff --git a/apps/web/src/page/alerts/status-pages/editor/view-settings.ts b/apps/web/src/page/alerts/status-pages/editor/view-settings.ts index 9ea078a43c..00cccc9065 100644 --- a/apps/web/src/page/alerts/status-pages/editor/view-settings.ts +++ b/apps/web/src/page/alerts/status-pages/editor/view-settings.ts @@ -312,10 +312,14 @@ export const settingsTab = (h: H, model: Model, page: V2StatusPage): Html => { ...saveBar(h, { isDirty: isSettingsDirty(model), isPending: model.isSavingSettings, - context: hasIssues ? "Fix the fields marked above." : undefined, + context: hasIssues + ? "Fix the fields marked above." + : model.isApplyingJson + ? "Saving waits for the JSON apply to finish." + : undefined, onDiscard: Message.ClickedDiscardSettings(), onSave: Message.ClickedSaveSettings(), - isSaveDisabled: hasIssues, + isSaveDisabled: hasIssues || model.isApplyingJson, }), ], ) diff --git a/apps/web/src/page/alerts/status-pages/editor/view.ts b/apps/web/src/page/alerts/status-pages/editor/view.ts index 4fe92c0256..1fd2e2a8cf 100644 --- a/apps/web/src/page/alerts/status-pages/editor/view.ts +++ b/apps/web/src/page/alerts/status-pages/editor/view.ts @@ -35,6 +35,7 @@ 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. @@ -45,6 +46,7 @@ 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 => @@ -129,6 +131,12 @@ const tabRead = (model: Model, tab: EditorTab): { failure: Option.Option = {}): 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, + 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/docs/api-v2.md b/docs/api-v2.md index 8614fc9745..f8ea4b97a1 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -158,43 +158,47 @@ 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`) | `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` + 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/`. The long tail of ~40 query-engine endpoints (facets, infra hosts/pods/nodes/workloads, Cloudflare infra, the PlanetScale infra timeseries) lives in the internal tier at `/internal/query-engine`, session-only and undocumented, and is promoted into `/v2` individually as shapes stabilize. +### Status page config document + +`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). `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. + ### 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/StatusPagesService.test.ts b/packages/backend/src/services/status-pages/StatusPagesService.test.ts index 24b444f143..60c7ebb6dc 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.test.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.test.ts @@ -2,6 +2,7 @@ import { afterEach, assert, describe, it } from "@effect/vitest" import { Effect, Layer, Schema } from "effect" import { TestClock } from "effect/testing" import { OrgId, UserId } from "@maple/domain/http" +import { StatusPageComponentPublicId, type StatusPageConfig } from "@maple/domain/http/v2" import { StatusPageComponentId, StatusPageId, UptimeMonitorId } from "@maple/domain/primitives" import { DEFAULT_STATUS_PAGE_THEME, @@ -539,4 +540,336 @@ describe("StatusPagesService", () => { assert.strictEqual(yield* revision(testDb, page.id), before) }).pipe(Effect.provide(makeLayer(testDb))) }) + + describe("config", () => { + const NO_CHANGES = { fields: [], created: [], updated: [], deleted: [], reordered: false } + const cleanupSlugs = (testDb: TestDb) => + Effect.promise(() => + testDb.pglite.query<{ slug: string }>( + `SELECT slug FROM status_page_cleanups WHERE delete_snapshot = false`, + ), + ).pipe(Effect.map((result) => result.rows.map((row) => row.slug))) + + const seeded = (testDb: TestDb) => + Effect.gen(function* () { + const service = yield* StatusPagesService + yield* seedMonitor(testDb, ORG, MONITOR) + yield* seedMonitor(testDb, OTHER_ORG, OTHER_MONITOR) + const page = yield* createPage("config") + const api = yield* service.createComponent(ORG, page.id, { name: "API", monitorId: MONITOR }) + const web = yield* service.createComponent(ORG, page.id, { name: "Web" }) + const docs = yield* service.createComponent(ORG, page.id, { name: "Docs" }) + return { service, page, api, web, docs, config: yield* service.getConfig(ORG, page.id) } + }) + + it.effect("reads the page and its components in order as one document", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { page, api, web, docs, config } = yield* seeded(testDb) + assert.match(config.version ?? "", /^[0-9a-z]+$/u) + assert.deepStrictEqual(config, { + version: config.version, + name: page.name, + slug: "config", + description: null, + website_url: null, + logo_url: null, + show_powered_by: true, + auto_incidents: "off", + theme: DEFAULT_STATUS_PAGE_THEME, + components: [api, web, docs].map((component) => ({ + id: component.id, + name: component.name, + description: null, + group: null, + monitor_id: component.monitorId, + manual_status: null, + show_uptime: true, + })), + }) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect( + "creates, updates, deletes and reorders components, with defaults for omitted fields", + () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, api, web, docs } = yield* seeded(testDb) + yield* service.update(ORG, page.id, { description: "Old", showPoweredBy: false }) + const before = yield* revision(testDb, page.id) + const { config: applied, changes } = yield* service.applyConfig( + ORG, + page.id, + { + name: "Acme", + slug: "config", + theme: { preset: "paper" }, + components: [ + { id: docs.id, name: "Docs site", group: "Web" }, + { name: "Status API", monitor_id: MONITOR, show_uptime: false }, + { id: api.id, name: "API", monitor_id: MONITOR }, + ], + }, + USER, + ) + assert.strictEqual(yield* revision(testDb, page.id), before + 1) + const pageNow = yield* service.get(ORG, page.id) + assert.strictEqual(pageNow.name, "Acme") + assert.isNull(pageNow.description) + assert.isTrue(pageNow.showPoweredBy) + assert.deepStrictEqual(pageNow.theme, { ...DEFAULT_STATUS_PAGE_THEME, preset: "paper" }) + + const listed = yield* service.listComponents(ORG, page.id) + assert.deepStrictEqual( + listed.map((row) => [ + row.name, + row.position, + row.groupName, + row.monitorId, + row.showUptime, + ]), + [ + ["Docs site", 0, "Web", null, true], + ["Status API", 1, null, MONITOR, false], + ["API", 2, null, MONITOR, true], + ], + ) + assert.strictEqual(listed[0]?.id, docs.id) + assert.strictEqual(listed[2]?.id, api.id) + assert.isFalse(listed.some((row) => row.id === web.id)) + assert.deepStrictEqual( + applied.components.map((component) => component.id), + listed.map((row) => row.id), + ) + assert.deepStrictEqual(applied, yield* service.getConfig(ORG, page.id)) + assert.deepStrictEqual(changes, { + fields: ["name", "description", "show_powered_by", "theme"], + created: ["Status API"], + updated: ["Docs site"], + deleted: ["Web"], + reordered: true, + }) + }).pipe(Effect.provide(makeLayer(testDb))) + }, + ) + + it.effect("re-applying the current document changes nothing and leaves the revision alone", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + const before = yield* revision(testDb, page.id) + const applied = yield* service.applyConfig(ORG, page.id, config, USER) + assert.deepStrictEqual(applied.config, config) + assert.deepStrictEqual(applied.changes, NO_CHANGES) + assert.strictEqual(yield* revision(testDb, page.id), before) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect( + "refuses ids of another page, duplicates, foreign monitors and bad themes, changing nothing", + () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + const other = yield* createPage("config-other") + const elsewhere = yield* service.createComponent(ORG, other.id, { name: "Elsewhere" }) + const foreignOrgPage = yield* createPage("config-foreign", OTHER_ORG) + const foreignOrg = yield* service.createComponent(OTHER_ORG, foreignOrgPage.id, { + name: "X", + }) + const before = yield* revision(testDb, page.id) + const first = config.components[0] + assert.isDefined(first) + const withRenamedPage = { ...config, name: "Renamed" } + const cases: ReadonlyArray = [ + [ + { + ...withRenamedPage, + components: [...config.components, { id: elsewhere.id, name: "E" }], + }, + `Component ${Schema.encodeSync(StatusPageComponentPublicId)(elsewhere.id)} is not on this page`, + ], + [ + { ...withRenamedPage, components: [{ id: foreignOrg.id, name: "X" }] }, + `Component ${Schema.encodeSync(StatusPageComponentPublicId)(foreignOrg.id)} is not on this page`, + ], + [ + { + ...withRenamedPage, + components: [...config.components, { ...first, name: "Twice" }], + }, + "is listed more than once", + ], + [ + { + ...withRenamedPage, + components: [{ name: "Foreign", monitor_id: OTHER_MONITOR }], + }, + "Unknown uptime monitor upm_", + ], + [{ ...withRenamedPage, theme: { appearance: "light", text: "#777777" } }, "4.48:1"], + [ + { ...withRenamedPage, components: [{ name: " " }] }, + "components[0].name is required", + ], + ] + yield* Effect.forEach(cases, ([input, message]) => + Effect.flip(service.applyConfig(ORG, page.id, input, USER)).pipe( + Effect.map((error) => { + assert.strictEqual(error._tag, "@maple/http/errors/StatusPageValidationError") + assert.include(error.message, message) + }), + ), + ) + assert.strictEqual(yield* revision(testDb, page.id), before) + assert.deepStrictEqual(yield* service.getConfig(ORG, page.id), config) + assert.strictEqual((yield* service.listComponents(ORG, other.id)).length, 1) + }).pipe(Effect.provide(makeLayer(testDb))) + }, + ) + + it.effect("queues the old slug for cleanup on a rename and refuses a taken slug", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + yield* createPage("taken-elsewhere", OTHER_ORG) + const taken = yield* Effect.flip( + service.applyConfig(ORG, page.id, { ...config, slug: "taken-elsewhere" }, USER), + ) + assert.strictEqual(taken._tag, "@maple/http/errors/StatusPageSlugTakenError") + assert.deepStrictEqual(yield* cleanupSlugs(testDb), []) + + const renamed = yield* service.applyConfig( + ORG, + page.id, + { ...config, slug: "config-new" }, + USER, + ) + assert.strictEqual(renamed.config.slug, "config-new") + assert.deepStrictEqual(yield* cleanupSlugs(testDb), ["config"]) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("reports no change for values that differ only until stored", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + yield* service.update(ORG, page.id, { + theme: { ...DEFAULT_STATUS_PAGE_THEME, appearance: "light", accent: "#1a2b3c" }, + }) + const current = yield* service.getConfig(ORG, page.id) + const [first, ...rest] = current.components + assert.isDefined(first) + const padded: StatusPageConfig = { + ...current, + name: ` ${current.name} `, + description: " ", + theme: { ...current.theme, accent: "#1A2B3C" }, + components: [{ ...first, name: ` ${first.name} `, group: " " }, ...rest], + } + const applied = yield* service.applyConfig(ORG, page.id, padded, USER) + assert.deepStrictEqual(applied.changes, NO_CHANGES) + assert.deepStrictEqual(applied.config.components, config.components) + assert.strictEqual(applied.config.theme?.accent, "#1a2b3c") + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("with if_version, an edited config is a conflict and nothing is written", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config, web } = yield* seeded(testDb) + yield* service.createComponent(ORG, page.id, { name: "Added meanwhile" }) + const moved = yield* service.getConfig(ORG, page.id) + assert.notStrictEqual(moved.version, config.version) + const conflict = yield* Effect.flip( + service.applyConfig(ORG, page.id, config, USER, { ifVersion: config.version }), + ) + assert.strictEqual(conflict._tag, "@maple/http/errors/StatusPageConflictError") + assert.include(conflict.message, "changed since it was read") + assert.deepStrictEqual(yield* service.getConfig(ORG, page.id), moved) + + const applied = yield* service.applyConfig(ORG, page.id, { ...moved, name: "Fresh" }, USER, { + ifVersion: moved.version, + }) + assert.strictEqual(applied.config.name, "Fresh") + assert.notStrictEqual(applied.config.version, moved.version) + assert.isTrue(applied.config.components.some((c) => c.id === web.id)) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("a status flip between read and apply is no conflict; a rename by someone else is", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config, web } = yield* seeded(testDb) + const before = yield* revision(testDb, page.id) + // What a monitor flip does to the page (`bumpPagesForMonitors`), and a reconcile's bump. + yield* Effect.promise(() => + executeSql( + testDb, + `UPDATE status_pages SET revision = revision + 1 WHERE id IN ( + SELECT page_id FROM status_page_components WHERE monitor_id = $1)`, + [MONITOR], + ), + ) + yield* Effect.promise(() => + executeSql(testDb, `UPDATE status_pages SET revision = revision + 1 WHERE id = $1`, [ + page.id, + ]), + ) + assert.strictEqual(yield* revision(testDb, page.id), before + 2) + const applied = yield* service.applyConfig( + ORG, + page.id, + { ...config, description: "Edited during an outage." }, + USER, + { ifVersion: config.version }, + ) + assert.deepStrictEqual(applied.changes.fields, ["description"]) + + const read = yield* service.getConfig(ORG, page.id) + yield* service.updateComponent(ORG, web.id, { name: "Web app" }) + const conflict = yield* Effect.flip( + service.applyConfig(ORG, page.id, { ...read, name: "Mine" }, USER, { + ifVersion: read.version, + }), + ) + assert.strictEqual(conflict._tag, "@maple/http/errors/StatusPageConflictError") + assert.strictEqual((yield* service.get(ORG, page.id)).name, read.name) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("caps a page at 200 components, for an apply and for a single create", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page } = yield* seeded(testDb) + const document = (count: number): StatusPageConfig => ({ + name: "Big", + slug: "config", + components: Array.from({ length: count }, (_, index) => ({ name: `C${index}` })), + }) + const tooMany = yield* Effect.flip(service.applyConfig(ORG, page.id, document(201), USER)) + assert.strictEqual(tooMany._tag, "@maple/http/errors/StatusPageValidationError") + assert.include(tooMany.message, "at most 200 components") + assert.lengthOf(yield* service.listComponents(ORG, page.id), 3) + + yield* service.applyConfig(ORG, page.id, document(200), USER) + const extra = yield* Effect.flip(service.createComponent(ORG, page.id, { name: "201st" })) + assert.strictEqual(extra._tag, "@maple/http/errors/StatusPageValidationError") + assert.include(extra.message, "at most 200 components") + assert.lengthOf(yield* service.listComponents(ORG, page.id), 200) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("a missing or cross-org page is not found", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + const read = yield* Effect.flip(service.getConfig(OTHER_ORG, page.id)) + assert.strictEqual(read._tag, "@maple/http/errors/StatusPageNotFoundError") + const write = yield* Effect.flip(service.applyConfig(OTHER_ORG, page.id, config, USER)) + assert.strictEqual(write._tag, "@maple/http/errors/StatusPageNotFoundError") + }).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 52ce61c66a..e6caab3b66 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.ts @@ -15,6 +15,7 @@ import { } from "@maple/db/tables" import { StatusPageComponentNotFoundError, + StatusPageConflictError, StatusPageIncidentNotFoundError, StatusPageNotFoundError, StatusPagePersistenceError, @@ -25,6 +26,17 @@ import { type StatusPageIncidentUpdateRecord, type StatusPageRecord, } from "@maple/domain/http" +import { + diffStatusPageConfig, + hasStatusPageConfigChanges, + MAX_STATUS_PAGE_COMPONENTS, + normalizeStatusPageConfig, + StatusPageComponentPublicId, + statusPageConfigVersion, + type StatusPageConfig, + type StatusPageConfigChangeSet, + UptimeMonitorPublicId, +} from "@maple/domain/http/v2" import { StatusPageComponentId, StatusPageId, @@ -220,6 +232,29 @@ export interface StatusPagesServiceApi { orgId: OrgId, incidentId: StatusPageIncidentId, ) => Effect.Effect + /** The page's settings, theme and components as one document. */ + readonly getConfig: (orgId: OrgId, pageId: StatusPageId) => Effect.Effect + /** + * Replaces the page with `config` in one transaction: all of it applies, or none of it. With + * `ifVersion`, a config edited since that version fails with a conflict and nothing is written. + */ + readonly applyConfig: ( + orgId: OrgId, + pageId: StatusPageId, + config: StatusPageConfig, + actor: UserId, + options?: { readonly ifVersion?: string | undefined }, + ) => Effect.Effect< + AppliedStatusPageConfig, + PageWriteError | StatusPageSlugTakenError | StatusPageConflictError + > +} + +/** The page before and after the write, and what changed, all read under the page lock. */ +export interface AppliedStatusPageConfig { + readonly previous: StatusPageConfig + readonly config: StatusPageConfig + readonly changes: StatusPageConfigChangeSet } const MAX_NAME_LENGTH = 120 @@ -451,6 +486,28 @@ const findPage = ( ) } +/** + * The page row, locked until the transaction ends. Every component write takes it first, so an + * apply and component edits on one page run one at a time. + */ +const lockPage = (db: MapleDb, orgId: OrgId, pageId: StatusPageId) => + db + .run( + PG.from(StatusPages) + .select() + .where(($) => [$.orgId.eq(orgId), $.id.eq(pageId)]) + .limit(1) + .forUpdate(), + ) + .pipe( + Effect.flatMap((rows) => + rows[0] === undefined ? Effect.fail(pageNotFound(pageId)) : Effect.succeed(rows[0]), + ), + ) + +const tooManyComponents = () => + invalid(`A status page can have at most ${MAX_STATUS_PAGE_COMPONENTS} components`) + const findComponent = (db: MapleDb, orgId: OrgId, componentId: StatusPageComponentId) => db .run( @@ -548,6 +605,76 @@ 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, + components: ReadonlyArray, +): StatusPageConfig => ({ + name: page.name, + slug: page.slug, + description: page.description, + website_url: page.websiteUrl, + logo_url: page.logoUrl, + show_powered_by: page.showPoweredBy, + auto_incidents: page.autoIncidents, + theme: page.theme, + components: components.map((component) => ({ + id: component.id, + name: component.name, + description: component.description, + group: component.groupName, + monitor_id: component.monitorId, + manual_status: component.manualStatus, + show_uptime: component.showUptime, + })), +}) + +/** The page's config as a read returns it, tagged with its content version. */ +const toConfig = ( + page: StatusPageRow, + components: ReadonlyArray, +): StatusPageConfig => { + const content = contentOf(page, components) + return { version: statusPageConfigVersion(content), ...content } +} + +/** One config component, validated and with its defaults, before any id is checked. */ +const validateConfigComponent = Effect.fnUntraced(function* ( + component: StatusPageConfig["components"][number], + index: number, +) { + const at = `components[${index}]` + return { + id: component.id ?? null, + name: yield* validateName(component.name, `${at}.name`), + description: yield* validateText(component.description ?? null, `${at}.description`), + groupName: yield* validateText(component.group ?? null, `${at}.group`), + monitorId: component.monitor_id ?? null, + manualStatus: component.manual_status ?? null, + showUptime: component.show_uptime ?? true, + } +}) + const newBreakGlassToken = () => `${STATUS_PAGE_BREAK_GLASS_PREFIX}${randomBytes(32).toString("base64url")}` /** Newest incidents a list returns: with history included, a long-lived page stays bounded. */ @@ -822,9 +949,11 @@ export class StatusPagesService extends Context.Service db.transaction( Effect.gen(function* () { - yield* findPage(db, orgId, pageId) + yield* lockPage(db, orgId, pageId) if (monitorId !== null) yield* ensureMonitorInOrg(db, orgId, monitorId) const existing = yield* selectComponents(db, orgId, pageId) + if (existing.length >= MAX_STATUS_PAGE_COMPONENTS) + return yield* tooManyComponents() const inserted = yield* db.run( PG.insertInto(StatusPageComponents) .values({ @@ -881,6 +1010,7 @@ export class StatusPagesService extends Context.Service [ $.orgId.eq(orgId), @@ -943,7 +1074,7 @@ export class StatusPagesService extends Context.Service db.transaction( Effect.gen(function* () { - yield* findPage(db, orgId, pageId) + yield* lockPage(db, orgId, pageId) const existing = yield* selectComponents(db, orgId, pageId) const requested = new Set(componentIds) const sameSet = @@ -1243,7 +1374,196 @@ export class StatusPagesService extends Context.Service + Effect.gen(function* () { + const page = yield* findPage(db, orgId, pageId) + return toConfig(page, yield* selectComponents(db, orgId, pageId)) + }), + ) + }) + + const applyConfig = Effect.fn("StatusPagesService.applyConfig")(function* ( + orgId: OrgId, + pageId: StatusPageId, + config: StatusPageConfig, + actor: UserId, + options?: { readonly ifVersion?: string | undefined }, + ) { + yield* Effect.annotateCurrentSpan({ + orgId, + "maple.status_page.id": pageId, + "maple.status_page.actor": actor, + "maple.status_page.config_components": config.components.length, + }) + const input = normalizeStatusPageConfig(config) + if (input.components.length > MAX_STATUS_PAGE_COMPONENTS) return yield* tooManyComponents() + const name = yield* validateName(input.name, "Name") + const slug = yield* validateSlug(input.slug) + const description = yield* validateText(input.description, "Description") + const websiteUrl = yield* validateHttpsUrl(input.website_url, "Website URL") + const logoUrl = yield* validateHttpsUrl(input.logo_url, "Logo URL") + const theme = yield* validateTheme(input.theme) + const components = yield* Effect.forEach(input.components, validateConfigComponent) + const listedIds = components.flatMap((component) => + component.id === null ? [] : [component.id], + ) + const duplicate = listedIds.find((id, index) => listedIds.indexOf(id) !== index) + if (duplicate !== undefined) + return yield* invalid( + `Component ${encodeComponentId(duplicate)} is listed more than once`, + ) + const monitorIds = [ + ...new Set(components.flatMap((c) => (c.monitorId === null ? [] : [c.monitorId]))), + ] + // The document as it will be stored; new components have no id yet. + const written: StatusPageConfig = { + name, + slug, + description, + website_url: websiteUrl, + logo_url: logoUrl, + show_powered_by: input.show_powered_by, + auto_incidents: input.auto_incidents, + theme, + components: components.map((component) => ({ + ...(component.id === null ? undefined : { id: component.id }), + name: component.name, + description: component.description, + group: component.groupName, + monitor_id: component.monitorId, + manual_status: component.manualStatus, + show_uptime: component.showUptime, + })), + } + const ifVersion = options?.ifVersion + const now = yield* Clock.currentTimeMillis + return yield* dbExecuteSlug(slug)((db) => + db.transaction( + Effect.gen(function* () { + // Locked before the components are read, so the change set below is the write's. + const current = yield* lockPage(db, orgId, pageId) + const existing = yield* selectComponents(db, orgId, pageId) + const previous = toConfig(current, existing) + const version = statusPageConfigVersion(previous) + // The content's version, not the page revision: status flips and publishes leave it. + if (ifVersion !== undefined && ifVersion !== version) + return yield* new StatusPageConflictError({ + pageId, + version, + message: + "The page's config changed since it was read. Read the config again and redo the edit on it.", + }) + const byId = new Map(existing.map((row) => [row.id, row])) + const foreign = listedIds.find((id) => !byId.has(id)) + if (foreign !== undefined) + return yield* invalid( + `Component ${encodeComponentId(foreign)} is not on this page`, + ) + yield* ensureMonitorsInOrg(db, orgId, monitorIds) + yield* ensureSlugFree(db, slug, pageId) + const { fields, created, updated, deleted, reordered } = diffStatusPageConfig( + previous, + written, + ) + // The old slug route stays at the edge until the publisher drains this. + if (slug !== current.slug) + yield* queueSlugRemoval(db, orgId, pageId, current.slug) + yield* db.run( + PG.update(StatusPages) + .set({ + name, + slug, + description, + websiteUrl, + logoUrl, + theme, + showPoweredBy: input.show_powered_by, + autoIncidents: input.auto_incidents, + updatedAt: now, + }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(pageId)]), + ) + const listed = new Set(listedIds) + const [firstRemoved, ...restRemoved] = existing + .filter((row) => !listed.has(row.id)) + .map((row) => row.id) + if (firstRemoved !== undefined) + yield* db.run( + PG.deleteFrom(StatusPageComponents).where(($) => [ + $.orgId.eq(orgId), + $.pageId.eq(pageId), + $.id.in_(firstRemoved, ...restRemoved), + ]), + ) + yield* Effect.forEach( + components, + (component, position) => { + const fields = { + name: component.name, + description: component.description, + groupName: component.groupName, + position, + monitorId: component.monitorId, + manualStatus: component.manualStatus, + showUptime: component.showUptime, + } + const previous = + component.id === null ? undefined : byId.get(component.id) + if (previous === undefined) + return db.run( + PG.insertInto(StatusPageComponents).values({ + ...fields, + id: StatusPageComponentId.make(randomUUID()), + orgId, + pageId, + createdAt: now, + updatedAt: now, + }), + ) + const unchanged = + previous.name === fields.name && + previous.description === fields.description && + previous.groupName === fields.groupName && + previous.position === fields.position && + previous.monitorId === fields.monitorId && + previous.manualStatus === fields.manualStatus && + previous.showUptime === fields.showUptime + return unchanged + ? Effect.void + : db.run( + PG.update(StatusPageComponents) + .set({ ...fields, updatedAt: now }) + .where(($) => [ + $.orgId.eq(orgId), + $.pageId.eq(pageId), + $.id.eq(previous.id), + ]), + ) + }, + { discard: true }, + ) + // A document that changes nothing leaves the revision alone, so nothing is republished. + if (hasStatusPageConfigChanges({ fields, created, updated, deleted, reordered })) + yield* bumpPage(db, orgId, pageId) + const page = yield* findPage(db, orgId, pageId) + return { + previous, + config: toConfig(page, yield* selectComponents(db, orgId, pageId)), + changes: { fields, created, updated, deleted, reordered }, + } + }), + ), + ) + }) + return { + getConfig, + applyConfig, list, get, create, diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index b12d880405..f6606609b5 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -128,6 +128,7 @@ export const AuditResources = { "deleted", "published", "components_reordered", + "config_applied", "break_glass_rotated", "break_glass_revoked", ], @@ -322,6 +323,7 @@ export const AuditActionLabels = { "status_page.deleted": "Deleted status page", "status_page.published": "Published status page", "status_page.components_reordered": "Reordered status page components", + "status_page.config_applied": "Applied status page config", "status_page.break_glass_rotated": "Rotated status page break-glass token", "status_page.break_glass_revoked": "Revoked status page break-glass token", "status_page_component.created": "Created status page component", diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index a571a548b2..375840b39d 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -107,6 +107,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/SourceNotFoundError", "@maple/http/errors/SourceValidationError", "@maple/http/errors/StatusPageComponentNotFoundError", + "@maple/http/errors/StatusPageConflictError", "@maple/http/errors/StatusPageIncidentNotFoundError", "@maple/http/errors/StatusPageNotFoundError", "@maple/http/errors/StatusPageSlugTakenError", diff --git a/packages/domain/src/http/status-pages.ts b/packages/domain/src/http/status-pages.ts index dd269bf8ee..befb3d10b8 100644 --- a/packages/domain/src/http/status-pages.ts +++ b/packages/domain/src/http/status-pages.ts @@ -104,6 +104,26 @@ export class StatusPageSlugTakenError extends HttpTaggedError()( + "@maple/http/errors/StatusPageConflictError", + { + pageId: StatusPageId, + /** The config's current version, for a caller that wants to compare. */ + version: Schema.String, + message: Schema.String, + }, + { + status: 409, + code: "status_page_conflict", + title: "Status page changed", + param: "if_version", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, +) {} + export class StatusPagePersistenceError extends HttpTaggedError()( "@maple/http/errors/StatusPagePersistenceError", { diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 0bcc6762e4..c032d23467 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -179,6 +179,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/status_pages", "GET /v2/status_pages/{id}", "GET /v2/status_pages/{id}/components", + "GET /v2/status_pages/{id}/config", "GET /v2/support/slack_channel", "GET /v2/traces/{trace_id}", "GET /v2/traces/{trace_id}/spans/{span_id}", @@ -285,6 +286,7 @@ describe("MapleApiV2 OpenAPI", () => { "PUT /v2/error_issues/{id}/severity", "PUT /v2/mobile_devices/{token}", "PUT /v2/mobile_devices/{token}/live_activities/{incident_id}", + "PUT /v2/status_pages/{id}/config", "PUT /v2/widget_credentials/{installation_id}", ]) }) diff --git a/packages/domain/src/http/v2/status-page-config.test.ts b/packages/domain/src/http/v2/status-page-config.test.ts new file mode 100644 index 0000000000..f64decc19a --- /dev/null +++ b/packages/domain/src/http/v2/status-page-config.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest" +import { Schema } from "effect" +import { StatusPageComponentId } from "../../primitives" +import { DEFAULT_STATUS_PAGE_THEME } from "../../status-page" +import { diffStatusPageConfig, StatusPageConfig, statusPageConfigVersion } from "./status-pages" + +const API = StatusPageComponentId.make("11111111-1111-4111-8111-111111111111") +const WEB = StatusPageComponentId.make("22222222-2222-4222-8222-222222222222") +const DOCS = StatusPageComponentId.make("33333333-3333-4333-8333-333333333333") +const STRAY = StatusPageComponentId.make("44444444-4444-4444-8444-444444444444") + +const current: StatusPageConfig = { + name: "Acme", + slug: "acme", + description: null, + website_url: null, + logo_url: null, + show_powered_by: true, + auto_incidents: "off", + theme: DEFAULT_STATUS_PAGE_THEME, + components: [ + { + id: API, + name: "API", + description: null, + group: null, + monitor_id: null, + manual_status: null, + show_uptime: true, + }, + { + id: WEB, + name: "Web", + description: null, + group: null, + monitor_id: null, + manual_status: null, + show_uptime: true, + }, + { + id: DOCS, + name: "Docs", + description: null, + group: null, + monitor_id: null, + manual_status: null, + show_uptime: true, + }, + ], +} + +describe("diffStatusPageConfig", () => { + it("reads omitted optional fields as their defaults, so a minimal copy changes nothing", () => { + const minimal: StatusPageConfig = { + name: "Acme", + slug: "acme", + components: [ + { id: API, name: "API" }, + { id: WEB, name: "Web" }, + { id: DOCS, name: "Docs" }, + ], + } + expect(diffStatusPageConfig(current, minimal)).toEqual({ + fields: [], + created: [], + updated: [], + deleted: [], + reordered: false, + unknownIds: [], + duplicateIds: [], + }) + }) + + it("lists changed fields, created, updated and deleted components and a new order", () => { + const changes = diffStatusPageConfig(current, { + ...current, + name: "Acme Inc", + theme: { preset: "paper" }, + components: [ + { id: DOCS, name: "Docs" }, + { id: API, name: "API", manual_status: "degraded" }, + { name: "Status" }, + ], + }) + expect(changes).toMatchObject({ + fields: ["name", "theme"], + created: ["Status"], + updated: ["API"], + deleted: ["Web"], + reordered: true, + }) + }) + + it("reads text and colours as an apply stores them, so padding or case is no change", () => { + const changes = diffStatusPageConfig( + { ...current, theme: { ...DEFAULT_STATUS_PAGE_THEME, accent: "#1a2b3c" } }, + { + ...current, + name: " Acme ", + description: " ", + theme: { ...DEFAULT_STATUS_PAGE_THEME, accent: " #1A2B3C" }, + components: current.components.map((component) => ({ + ...component, + name: ` ${component.name} `, + group: "", + })), + }, + ) + expect(changes).toMatchObject({ fields: [], updated: [] }) + }) + + it("flags ids that are not on the page and ids listed twice", () => { + const changes = diffStatusPageConfig(current, { + ...current, + components: [ + { id: API, name: "API" }, + { id: API, name: "API again" }, + { id: STRAY, name: "Stray" }, + ], + }) + expect(changes.unknownIds).toEqual([STRAY]) + expect(changes.duplicateIds).toEqual([API]) + }) + + it("decodes public ids on the wire and refuses a component without a name", () => { + const decode = Schema.decodeUnknownSync(StatusPageConfig) + const encoded = Schema.encodeSync(StatusPageConfig)(current) + expect(encoded.components[0]?.id).toMatch(/^stc_/) + expect(decode(encoded)).toEqual(current) + expect(() => decode({ name: "Acme", slug: "acme", components: [{ name: "" }] })).toThrow() + }) + + it("refuses more than 200 components", () => { + const decode = Schema.decodeUnknownExit(StatusPageConfig) + const document = (count: number) => ({ + name: "Acme", + slug: "acme", + components: Array.from({ length: count }, (_, index) => ({ name: `C${index}` })), + }) + expect(decode(document(200))._tag).toBe("Success") + expect(decode(document(201))._tag).toBe("Failure") + }) +}) + +describe("statusPageConfigVersion", () => { + const version = statusPageConfigVersion(current) + + it("is the same short tag for equal documents, whatever their version key says", () => { + expect(version).toMatch(/^[0-9a-z]{1,11}$/u) + expect(statusPageConfigVersion(structuredClone(current))).toBe(version) + expect(statusPageConfigVersion({ ...current, version: "anything" })).toBe(version) + }) + + it("changes when a component changes, is added or moves", () => { + const renamed = current.components.map((component) => + component.id === API ? { ...component, name: "API v2" } : component, + ) + expect(statusPageConfigVersion({ ...current, components: renamed })).not.toBe(version) + expect( + statusPageConfigVersion({ ...current, components: [...current.components, { name: "New" }] }), + ).not.toBe(version) + expect( + statusPageConfigVersion({ ...current, components: [...current.components].reverse() }), + ).not.toBe(version) + }) + + it("ignores key order", () => { + const reordered: StatusPageConfig = { + components: current.components.map((component) => ({ + show_uptime: component.show_uptime, + manual_status: component.manual_status, + monitor_id: component.monitor_id, + group: component.group, + description: component.description, + name: component.name, + id: component.id, + })), + theme: { + font: DEFAULT_STATUS_PAGE_THEME.font, + radius: DEFAULT_STATUS_PAGE_THEME.radius, + appearance: DEFAULT_STATUS_PAGE_THEME.appearance, + preset: DEFAULT_STATUS_PAGE_THEME.preset, + }, + auto_incidents: current.auto_incidents, + show_powered_by: current.show_powered_by, + logo_url: current.logo_url, + website_url: current.website_url, + description: current.description, + slug: current.slug, + name: current.name, + } + expect(statusPageConfigVersion(reordered)).toBe(version) + }) + + it("ignores differences an apply would normalize away: padding, blanks, hex case, defaults", () => { + const accented = { ...current, theme: { ...DEFAULT_STATUS_PAGE_THEME, accent: "#1a2b3c" } } + expect( + statusPageConfigVersion({ + ...accented, + name: " Acme ", + description: " ", + theme: { ...DEFAULT_STATUS_PAGE_THEME, accent: " #1A2B3C" }, + components: current.components.map((component) => ({ + id: component.id, + name: ` ${component.name}`, + })), + }), + ).toBe(statusPageConfigVersion(accented)) + }) +}) diff --git a/packages/domain/src/http/v2/status-pages.ts b/packages/domain/src/http/v2/status-pages.ts index 42c8732336..f78388553d 100644 --- a/packages/domain/src/http/v2/status-pages.ts +++ b/packages/domain/src/http/v2/status-pages.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" import { Schema } from "effect" import { StatusPageAutoIncidents, @@ -13,6 +13,7 @@ import { StatusPageUpdateSource, } from "../../primitives" import { + DEFAULT_STATUS_PAGE_THEME, StatusPageThemeAppearance, StatusPageThemeFont, StatusPageThemePreset, @@ -20,6 +21,7 @@ import { } from "../../status-page" import { StatusPageComponentNotFoundError, + StatusPageConflictError, StatusPageIncidentNotFoundError, StatusPageNotFoundError, StatusPagePersistenceError, @@ -399,6 +401,269 @@ const StatusPageComponentList = ListOf(V2StatusPageComponent).annotate({ description: "A page's components in display order.", }) +// --- Config document --- + +const configExample = { + name: "Acme Status", + slug: "acme-status", + description: "Live status of Acme's API and dashboard.", + website_url: "https://acme.example", + logo_url: null, + show_powered_by: true, + auto_incidents: "draft", + theme: themeExample, + components: [ + { + id: "stc_YofPTrK9782DWwcnXhpcCw", + name: "API", + description: null, + group: null, + monitor_id: "upm_YofPTrK9782DWwcnXhpcCw", + manual_status: null, + show_uptime: true, + }, + { name: "Docs", group: "Websites" }, + ], +} as const + +export const StatusPageConfigTheme = Schema.Struct({ + preset: Schema.optionalKey(themeFields.preset), + appearance: Schema.optionalKey(themeFields.appearance), + accent: Schema.optionalKey(themeFields.accent), + background: Schema.optionalKey(themeFields.background), + text: Schema.optionalKey(themeFields.text), + card: Schema.optionalKey(themeFields.card), + border: Schema.optionalKey(themeFields.border), + radius: Schema.optionalKey(themeFields.radius), + font: Schema.optionalKey(themeFields.font), +}).annotate({ + identifier: "StatusPageConfigTheme", + title: "Status page config theme", + description: "The page theme. Omitted fields take the default theme's value.", + 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.", + }), + ), + name: NonEmptyString.annotate({ examples: ["API"] }), + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + group: Schema.optionalKey( + Schema.NullOr(Schema.String).annotate({ description: "Heading the component is listed under." }), + ), + monitor_id: Schema.optionalKey( + Schema.NullOr(UptimeMonitorPublicId).annotate({ + description: "The uptime monitor (`upm_…`) driving its status, or `null`.", + }), + ), + manual_status: Schema.optionalKey(Schema.NullOr(componentStatusField)), + show_uptime: Schema.optionalKey( + Schema.Boolean.annotate({ description: "Whether the page shows its uptime bars. Default `true`." }), + ), +}).annotate({ + identifier: "StatusPageConfigComponent", + title: "Status page config component", + description: "One component; its place in the list is its position on the page.", +}) +export type StatusPageConfigComponent = 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 StatusPageConfig = Schema.Struct({ + version: Schema.optionalKey( + Schema.String.annotate({ + description: + "A tag of this document's content as read. Read-only: an apply ignores it; send it back as `if_version` to refuse the apply if the config changed since.", + examples: ["3k9x2m1q7z"], + }), + ), + name: NonEmptyString.annotate({ description: "Page title.", 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)), + show_powered_by: Schema.optionalKey( + Schema.Boolean.annotate({ description: "Whether the page credits Maple. Default `true`." }), + ), + auto_incidents: Schema.optionalKey(autoIncidentsField), + theme: Schema.optionalKey(StatusPageConfigTheme), + components: Schema.Array(StatusPageConfigComponent) + .check(Schema.isMaxLength(MAX_STATUS_PAGE_COMPONENTS)) + .annotate({ + description: `Every component, in display order (at most ${MAX_STATUS_PAGE_COMPONENTS}). Listed with an \`id\` updates it, without one adds it, and a component left out is deleted.`, + }), +}).annotate({ + identifier: "StatusPageConfig", + title: "Status page config", + description: + "A whole status page as one document: its settings, theme and components, without incidents, the break-glass token or publish state. Applying it replaces the page; omitted optional fields take their defaults.", + examples: [wireExample(configExample)], +}) +export type StatusPageConfig = 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() ?? "" + return trimmed.length === 0 ? null : trimmed +} + +const hexOrNull = (value: string | null | undefined): string | null => + textOrNull(value)?.toLowerCase() ?? null + +/** + * A config as an apply would store it: defaults filled in, text trimmed, blank text `null`, + * hex colours lower-cased. The apply and every preview of it diff through this. + */ +export const normalizeStatusPageConfig = (config: StatusPageConfig) => { + const theme = { ...DEFAULT_STATUS_PAGE_THEME, ...config.theme } + return { + name: config.name.trim(), + slug: config.slug, + description: textOrNull(config.description), + website_url: textOrNull(config.website_url), + logo_url: textOrNull(config.logo_url), + show_powered_by: config.show_powered_by ?? true, + auto_incidents: config.auto_incidents ?? "off", + theme: { + ...theme, + accent: hexOrNull(theme.accent), + background: hexOrNull(theme.background), + text: hexOrNull(theme.text), + card: hexOrNull(theme.card), + border: hexOrNull(theme.border), + }, + components: config.components.map((component) => ({ + ...(component.id === undefined ? undefined : { id: component.id }), + name: component.name.trim(), + description: textOrNull(component.description), + group: textOrNull(component.group), + monitor_id: component.monitor_id ?? null, + manual_status: component.manual_status ?? null, + show_uptime: component.show_uptime ?? true, + })), + } +} + +/** JSON with object keys sorted at every depth; arrays keep their order. */ +const canonicalJson = (value: unknown): string => { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]` + if (typeof value === "object" && value !== null) + return `{${Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(",")}}` + return JSON.stringify(value) ?? "null" +} + +/** cyrb53 over UTF-16 code units: 53 bits, the same in browsers, workerd and Node. */ +const cyrb53 = (text: string): number => { + const seed: readonly [number, number] = [0xdeadbeef, 0x41c6ce57] + const [a, b] = Array.from({ length: text.length }, (_, index) => text.charCodeAt(index)).reduce( + ([h1, h2], code): readonly [number, number] => [ + Math.imul(h1 ^ code, 2654435761), + Math.imul(h2 ^ code, 1597334677), + ], + seed, + ) + const a1 = Math.imul(a ^ (a >>> 16), 2246822507) ^ Math.imul(b ^ (b >>> 13), 3266489909) + const b1 = Math.imul(b ^ (b >>> 16), 2246822507) ^ Math.imul(a1 ^ (a1 >>> 13), 3266489909) + return 4294967296 * (2097151 & b1) + (a1 >>> 0) +} + +/** + * The version of a config's content: a hash of its normalized, canonical form, so only an edit + * to the document changes it. An optimistic-concurrency tag, not a security boundary. + */ +export const statusPageConfigVersion = (config: StatusPageConfig): string => + cyrb53(canonicalJson(normalizeStatusPageConfig(config))).toString(36) + +const PAGE_CONFIG_FIELDS = [ + "name", + "slug", + "description", + "website_url", + "logo_url", + "show_powered_by", + "auto_incidents", + "theme", +] as const + +const COMPONENT_CONFIG_FIELDS = [ + "name", + "description", + "group", + "monitor_id", + "manual_status", + "show_uptime", +] as const + +/** What an apply changed: page fields, and components by name. */ +export interface StatusPageConfigChangeSet { + readonly fields: ReadonlyArray<(typeof PAGE_CONFIG_FIELDS)[number]> + readonly created: ReadonlyArray + readonly updated: ReadonlyArray + readonly deleted: ReadonlyArray + readonly reordered: boolean +} + +/** Whether an apply changed anything; a no-op apply neither republishes nor audits. */ +export const hasStatusPageConfigChanges = (changes: StatusPageConfigChangeSet): boolean => + changes.fields.length + changes.created.length + changes.updated.length + changes.deleted.length > 0 || + changes.reordered + +export interface StatusPageConfigChanges extends StatusPageConfigChangeSet { + /** Ids listed that are not components of the page, or listed twice: the apply refuses both. */ + readonly unknownIds: ReadonlyArray + readonly duplicateIds: ReadonlyArray +} + +const sameValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right) + +/** What applying `next` over the page's current config would change; components by name. */ +export const diffStatusPageConfig = ( + current: StatusPageConfig, + next: StatusPageConfig, +): StatusPageConfigChanges => { + const before = normalizeStatusPageConfig(current) + const after = normalizeStatusPageConfig(next) + const existing = new Map( + before.components.flatMap((component) => + component.id === undefined ? [] : [[component.id, component] as const], + ), + ) + const listedIds = after.components.flatMap((component) => + component.id === undefined ? [] : [component.id], + ) + const listed = new Set(listedIds) + const keptOrder = listedIds.filter((id) => existing.has(id)) + return { + fields: PAGE_CONFIG_FIELDS.filter((field) => !sameValue(before[field], after[field])), + created: after.components.filter((component) => component.id === undefined).map((c) => c.name), + updated: after.components.flatMap((component) => { + const previous = component.id === undefined ? undefined : existing.get(component.id) + if (previous === undefined) return [] + const changed = COMPONENT_CONFIG_FIELDS.some( + (field) => !sameValue(previous[field], component[field]), + ) + return changed ? [component.name] : [] + }), + deleted: before.components + .filter((component) => component.id !== undefined && !listed.has(component.id)) + .map((component) => component.name), + reordered: !sameValue( + keptOrder, + [...existing.keys()].filter((id) => listed.has(id)), + ), + unknownIds: [...listed].filter((id) => !existing.has(id)), + duplicateIds: [...listed].filter((id) => listedIds.indexOf(id) !== listedIds.lastIndexOf(id)), + } +} + // --- Incidents --- export const V2StatusPageIncidentUpdate = Schema.Struct({ @@ -571,14 +836,16 @@ const IncidentsQuery = Schema.Struct({ description: "Pagination plus optional page and state filters.", }) -const [pageNotFound, componentNotFound, incidentNotFound, validation, slugTaken, persistence] = publicErrors( - StatusPageNotFoundError, - StatusPageComponentNotFoundError, - StatusPageIncidentNotFoundError, - StatusPageValidationError, - StatusPageSlugTakenError, - StatusPagePersistenceError, -) +const [pageNotFound, componentNotFound, incidentNotFound, validation, slugTaken, conflict, persistence] = + publicErrors( + StatusPageNotFoundError, + StatusPageComponentNotFoundError, + StatusPageIncidentNotFoundError, + StatusPageValidationError, + StatusPageSlugTakenError, + StatusPageConflictError, + StatusPagePersistenceError, + ) const StatusPageList = ListOf(V2StatusPage).annotate({ identifier: "StatusPageList", @@ -592,6 +859,19 @@ const StatusPageIncidentList = ListOf(V2StatusPageIncident).annotate({ description: "A cursor-paginated page of status page incidents, newest first.", }) +const ApplyConfigQuery = Schema.Struct({ + if_version: Schema.optional( + Schema.String.annotate({ + description: + "The `version` the document was read with. When the page's config has changed since, the apply answers 409 `status_page_conflict` and changes nothing.", + }), + ), +}).annotate({ + identifier: "StatusPageConfigApplyQuery", + title: "Status page config apply query", + description: "An optional precondition on the config version.", +}) + const pageParams = { id: StatusPagePublicId } const componentParams = { id: StatusPagePublicId, component_id: StatusPageComponentPublicId } const adminNote = "Requires the `status_pages:write` scope and an org-admin caller." @@ -704,6 +984,46 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") }), ), ) + .add( + HttpApiEndpoint.get("getConfig", "/:id/config", { + params: pageParams, + success: StatusPageConfig, + error: [pageNotFound, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getStatusPageConfig", + summary: "Retrieve a status page config", + description: + "Returns the page's settings, theme and components as one document, the body `PUT …/config` takes, with its `version`. Requires the `status_pages:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.put("applyConfig", "/:id/config", { + params: pageParams, + query: ApplyConfigQuery, + payload: StatusPageConfig, + success: StatusPageConfig, + error: [ + V2InsufficientPermissions.schema, + V2ParameterInvalid.schema, + pageNotFound, + validation, + slugTaken, + conflict, + persistence, + ], + }) + // A misspelt key would otherwise be dropped and its field reset to the default. + .annotate(HttpApi.PayloadParseOptions, { onExcessProperty: "error" }) + .annotateMerge( + OpenApi.annotations({ + identifier: "applyStatusPageConfig", + summary: "Apply a status page config", + description: `Replaces the page with the document in one write: components listed with an \`id\` are updated, without one created, and left out deleted; list order is display order. Any invalid part, or a key the document does not define, rejects the whole document. With \`if_version\`, a config that changed since that version was read answers 409 and nothing is written; status flips and publishes do not change it. Returns the applied config. ${adminNote}`, + }), + ), + ) .add( HttpApiEndpoint.get("listComponents", "/:id/components", { params: pageParams, @@ -729,7 +1049,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") OpenApi.annotations({ identifier: "createStatusPageComponent", summary: "Add a status page component", - description: `Adds a component after the existing ones. ${adminNote}`, + description: `Adds a component after the existing ones; a page holds at most ${MAX_STATUS_PAGE_COMPONENTS}. ${adminNote}`, }), ), ) diff --git a/packages/domain/src/mcp-outputs/catalog.ts b/packages/domain/src/mcp-outputs/catalog.ts index e790b2e815..e8d546e752 100644 --- a/packages/domain/src/mcp-outputs/catalog.ts +++ b/packages/domain/src/mcp-outputs/catalog.ts @@ -6,7 +6,13 @@ import { SendMapleFeedbackOutput } from "./feedback" import { InspectInfraOutput, ListInfraOutput } from "./infra" import { ListSourcesOutput } from "./sources" import { DeleteUptimeMonitorOutput, ListUptimeMonitorsOutput, UptimeMonitorMutationOutput } from "./uptime" -import { GetStatusPageOutput, ListStatusPagesOutput, StatusPageIncidentMutationOutput } from "./status-pages" +import { + ApplyStatusPageConfigOutput, + GetStatusPageConfigOutput, + GetStatusPageOutput, + ListStatusPagesOutput, + StatusPageIncidentMutationOutput, +} from "./status-pages" import { DeleteIngestRuleOutput, IngestRuleMutationOutput, @@ -177,6 +183,8 @@ export const McpToolOutputs = { list_status_pages: ListStatusPagesOutput, list_uptime_monitors: ListUptimeMonitorsOutput, get_status_page: GetStatusPageOutput, + get_status_page_config: GetStatusPageConfigOutput, + apply_status_page_config: ApplyStatusPageConfigOutput, post_status_page_update: StatusPageIncidentMutationOutput, resolve_status_page_incident: StatusPageIncidentMutationOutput, schedule_status_page_maintenance: StatusPageIncidentMutationOutput, diff --git a/packages/domain/src/mcp-outputs/status-pages.ts b/packages/domain/src/mcp-outputs/status-pages.ts index 2ebaa05ba8..5bd3839738 100644 --- a/packages/domain/src/mcp-outputs/status-pages.ts +++ b/packages/domain/src/mcp-outputs/status-pages.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { StatusPageConfig } from "../http/v2/status-pages" import { StatusPageAutoIncidents, StatusPageComponentStatus, @@ -90,3 +91,28 @@ export const StatusPageIncidentMutationOutput = Schema.Struct({ action: Schema.Literals(["created", "updated", "resolved", "scheduled"]), incident: StatusPageIncidentRow, }) + +// --------------------------------------------------------------------------------------------- +// get_status_page_config / apply_status_page_config + +export const GetStatusPageConfigOutput = Schema.Struct({ + page: StatusPageRow, + /** The document `PUT /v2/status_pages/{id}/config` and apply_status_page_config take. */ + config: StatusPageConfig, +}) + +const StatusPageConfigChangesRow = Schema.Struct({ + /** Page fields whose value changed. */ + fields: Schema.Array(Schema.String), + /** Component names, as listed in the applied config. */ + created: Schema.Array(Schema.String), + updated: Schema.Array(Schema.String), + deleted: Schema.Array(Schema.String), + reordered: Schema.Boolean, +}) + +export const ApplyStatusPageConfigOutput = Schema.Struct({ + page: StatusPageRow, + config: StatusPageConfig, + changes: StatusPageConfigChangesRow, +})