diff --git a/.oxlintrc.json b/.oxlintrc.json index ed148c8edd..d19b795be0 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -309,6 +309,23 @@ "excludeFiles": ["**/*.test.ts"], "rules": { "maple/mcp-tracer-anticipated-errors": "error" } }, + // The status Worker must keep serving while Maple is down: no Postgres, auth or backend services. + { + "files": ["apps/status/**/*.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "regex": "^(?:@maple/backend|@maple/db|@maple-dev/effect-orm|@maple/auth|@clerk/)", + "message": "apps/status serves pages while Maple is down; it reads only KV, R2 and the colo cache. Share pure code through @maple/domain or @maple/status-page." + } + ] + } + ] + } + }, // Customer SDK bundle boundaries (were source-grepping architecture tests). Later overrides // replace earlier rule options, so the narrower tiers repeat the broader patterns. { diff --git a/alchemy.run.ts b/alchemy.run.ts index f673f1e624..37c0fdf95b 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -50,6 +50,7 @@ import MapleApi from "./apps/api/src/worker.ts" import MapleSandbox from "./apps/sandbox/alchemy.run.ts" import { createMapleElectric } from "./apps/electric/alchemy.run.ts" import ElectricSync from "./apps/electric-sync/src/worker.ts" +import StatusWorker from "./apps/status/src/worker.ts" import { createMapleIngest } from "./apps/ingest/alchemy.run.ts" import { createMapleScraper } from "./apps/scraper/alchemy.run.ts" import Landing from "./apps/landing/src/worker.ts" @@ -364,6 +365,10 @@ export default Alchemy.Stack( const electricSync = yield* ElectricSync yield* serveWorker("electric-sync", electricSync) + // Public status pages, served from edge storage alone so they outlive a Maple outage. + const statusWorker = yield* StatusWorker + yield* serveWorker("status", statusWorker) + // A vite-source Worker: alchemy owns its build and dev server. Binds api as `API`. const web = yield* Effect.provideService(Web, ApiWorker, api) yield* serveWorker("web", web) diff --git a/apps/status/bunfig.toml b/apps/status/bunfig.toml new file mode 100644 index 0000000000..8e9e807026 --- /dev/null +++ b/apps/status/bunfig.toml @@ -0,0 +1,3 @@ +# Vitest package: `bun test` fails fast here (scripts/bun-test-guard.ts). +[test] +preload = ["../../scripts/bun-test-guard.ts"] diff --git a/apps/status/package.json b/apps/status/package.json new file mode 100644 index 0000000000..2af7b36619 --- /dev/null +++ b/apps/status/package.json @@ -0,0 +1,23 @@ +{ + "name": "@maple/status", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsrs --noEmit" + }, + "dependencies": { + "@alchemy.run/cloudflare": "catalog:alchemy", + "@maple/domain": "workspace:*", + "@maple/infra": "workspace:*", + "@maple/status-page": "workspace:*", + "alchemy": "catalog:alchemy", + "effect": "catalog:effect" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", + "@types/node": "catalog:tooling", + "typescript": "catalog:tooling", + "vitest": "catalog:" + } +} diff --git a/apps/status/src/break-glass.ts b/apps/status/src/break-glass.ts new file mode 100644 index 0000000000..f66e45be9a --- /dev/null +++ b/apps/status/src/break-glass.ts @@ -0,0 +1,194 @@ +import { StatusPageIncidentImpact, StatusPageIncidentStatus } from "@maple/domain/primitives" +import { + sha256Base64Url, + STATUS_PAGE_OVERLAY_MAX_ENTRIES, + StatusPageKeys, + type StatusPageOverlayEntry, + type StatusPageSnapshot, + statusFitsKind, +} from "@maple/domain/status-page" +import { MANAGE_FIELDS, NEW_INCIDENT, NO_CHANGE } from "@maple/status-page" +import { Effect, Option, Schema } from "effect" +import type { StatusEnv, StatusStorageError } from "./ports" +import { listOverlay, rememberOverlay } from "./snapshot" + +/** A break-glass post that was refused; `status` is the HTTP status the form page answers with. */ +export class BreakGlassRejected extends Schema.TaggedError()( + "@maple/status/BreakGlassRejected", + { message: Schema.String, status: Schema.Number }, +) {} + +/** The overlay could not be read or written, so the update was not saved. */ +export class BreakGlassNotSaved extends Schema.TaggedError()( + "@maple/status/BreakGlassNotSaved", + { message: Schema.String, cause: Schema.optionalKey(Schema.Defect()) }, +) {} + +/** Posted forms are small; anything larger is not a person typing an update. */ +export const MAX_FORM_BYTES = 32 * 1024 + +export const BreakGlassForm = Schema.Struct({ + token: Schema.String.check(Schema.isMinLength(1)), + incidentId: Schema.String, + title: Schema.String.check(Schema.isMaxLength(200)), + /** Null is "No change"; a new incident then starts `investigating`. */ + status: Schema.NullOr(StatusPageIncidentStatus), + /** Null is "No change"; a new incident then starts `major`. */ + impact: Schema.NullOr(StatusPageIncidentImpact), + body: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(5000)), + componentIds: Schema.Array(Schema.String), +}) +export type BreakGlassForm = typeof BreakGlassForm.Type + +const decodeForm = Schema.decodeUnknownOption(BreakGlassForm) + +const reject = (status: number, message: string) => Effect.fail(new BreakGlassRejected({ status, message })) + +const unreadable = () => new BreakGlassRejected({ status: 400, message: "The form could not be read." }) +const TOO_LARGE = "The update is too large." + +/** Compares every character whatever the first mismatch, so timing does not leak the digest. */ +export const constantTimeEqual = (a: string, b: string): boolean => + Array.from( + { length: Math.max(a.length, b.length) }, + (_, index) => a.charCodeAt(index) ^ b.charCodeAt(index), + ).reduce((diff, bits) => diff | bits, a.length ^ b.length) === 0 + +const text = (form: FormData, name: string): string => { + const value = form.get(name) + return typeof value === "string" ? value.trim() : "" +} + +const orNoChange = (value: string): string | null => (value === NO_CHANGE ? null : value) + +/** Reads the body chunk by chunk and stops past the cap, whatever `content-length` claims. */ +const readCapped = ( + reader: ReadableStreamDefaultReader, + chunks: ReadonlyArray, + total: number, +): Effect.Effect, BreakGlassRejected> => + Effect.tryPromise({ try: () => reader.read(), catch: unreadable }).pipe( + Effect.flatMap((chunk) => { + if (chunk.done) return Effect.succeed(chunks) + const size = total + chunk.value.byteLength + if (size > MAX_FORM_BYTES) { + return Effect.tryPromise(() => reader.cancel()).pipe( + Effect.ignore, + Effect.andThen(reject(413, TOO_LARGE)), + ) + } + return readCapped(reader, [...chunks, chunk.value], size) + }), + ) + +const readFormData = (request: Request): Effect.Effect => + Effect.gen(function* () { + if (Number(request.headers.get("content-length") ?? "0") > MAX_FORM_BYTES) { + return yield* reject(413, TOO_LARGE) + } + const chunks = request.body === null ? [] : yield* readCapped(request.body.getReader(), [], 0) + const bytes = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.byteLength, 0)) + chunks.reduce((offset, chunk) => { + bytes.set(chunk, offset) + return offset + chunk.byteLength + }, 0) + const contentType = request.headers.get("content-type") ?? "" + return yield* Effect.tryPromise({ + try: () => new Response(bytes, { headers: { "content-type": contentType } }).formData(), + catch: unreadable, + }) + }) + +/** Reads the posted form into a validated update, or says what is wrong with it. */ +export const parseForm = (request: Request): Effect.Effect => + Effect.gen(function* () { + const form = yield* readFormData(request) + const decoded = decodeForm({ + token: text(form, MANAGE_FIELDS.token), + incidentId: text(form, MANAGE_FIELDS.incidentId), + title: text(form, MANAGE_FIELDS.title), + status: orNoChange(text(form, MANAGE_FIELDS.status)), + impact: orNoChange(text(form, MANAGE_FIELDS.impact)), + body: text(form, MANAGE_FIELDS.body), + componentIds: form + .getAll(MANAGE_FIELDS.componentIds) + .filter((value) => typeof value === "string"), + }) + if (Option.isNone(decoded)) { + return yield* reject( + 400, + "Check the form: a token and a message of 1 to 5000 characters are required, status and impact must be listed values, and titles are at most 200 characters.", + ) + } + return decoded.value + }) + +const notSaved = (message: string) => (cause: StatusStorageError) => + new BreakGlassNotSaved({ message, cause }) + +/** + * Checks the token and the form against the page as visitors see it (overlay applied), then writes + * the update under its own KV key. Nothing shared is rewritten, so concurrent posts cannot drop one. + */ +export const postBreakGlassUpdate = ( + env: StatusEnv, + snapshot: StatusPageSnapshot, + form: BreakGlassForm, +): Effect.Effect => + Effect.gen(function* () { + const expected = snapshot.breakGlassTokenSha256 + if (expected === null) return yield* reject(403, "Break-glass updates are not enabled for this page.") + const digest = yield* Effect.promise(() => sha256Base64Url(form.token)) + if (!constantTimeEqual(digest, expected)) + return yield* reject(403, "That token is not valid for this page.") + + const isNew = form.incidentId === NEW_INCIDENT || form.incidentId === "" + if (isNew && form.title === "") return yield* reject(400, "Give the new incident a title.") + const target = isNew + ? undefined + : snapshot.incidents.find((incident) => incident.id === form.incidentId) + if (!isNew && target === undefined) return yield* reject(400, "That incident is not on this page.") + const kind = target?.kind ?? "incident" + if (form.status !== null && !statusFitsKind(kind, form.status)) { + return yield* reject( + 400, + kind === "incident" + ? "That status is for maintenance; pick an incident status." + : "That status is for incidents; pick a maintenance status.", + ) + } + // Deduped, and every id must be one of the page's components, so at most that many remain. + const componentIds = [...new Set(form.componentIds)] + const known = new Set(snapshot.components.map((component) => component.id)) + if (!componentIds.every((id) => known.has(id))) { + return yield* reject(400, "One of the selected components is not on this page.") + } + + const listed = yield* listOverlay(env, snapshot.pageId).pipe( + Effect.mapError(notSaved("The overlay could not be read.")), + ) + if (listed.keys.length >= STATUS_PAGE_OVERLAY_MAX_ENTRIES) { + return yield* reject( + 409, + "This page holds the most break-glass updates it can until Maple imports them.", + ) + } + + const id = env.randomId() + const entry: StatusPageOverlayEntry = { + id, + at: env.nowMs, + incidentId: isNew ? null : form.incidentId, + title: form.title === "" ? null : form.title, + status: isNew ? (form.status ?? "investigating") : form.status, + impact: isNew ? (form.impact ?? "major") : form.impact, + body: form.body, + componentIds, + } + yield* env.storage + .kvPut(StatusPageKeys.overlayEntry(snapshot.pageId, id), JSON.stringify(entry)) + .pipe(Effect.mapError(notSaved("The overlay could not be written."))) + // KV `list` lags writes, so the colo cache carries the new entry until the list catches up. + yield* rememberOverlay(env, snapshot.pageId, [...listed.entries, entry]) + return entry + }) diff --git a/apps/status/src/handler.test.ts b/apps/status/src/handler.test.ts new file mode 100644 index 0000000000..5bc34fb5e7 --- /dev/null +++ b/apps/status/src/handler.test.ts @@ -0,0 +1,458 @@ +import { sha256Base64Url, type StatusPageOverlayEntry, StatusPageKeys } from "@maple/domain/status-page" +import { + FIXTURE_NOW, + FIXTURE_PAGE_ID, + fixtureComponentId, + makeIncident, + makeSnapshot, +} from "@maple/status-page/fixtures" +import { beforeEach, describe, expect, it } from "vitest" +import { cacheKey } from "./snapshot" +import { + type FakeStorage, + makeFakeStorage, + overlayEntries, + postForm, + publish, + putOverlayEntry, + send, +} from "./test-support" + +const PAGE = "https://status.acme.example/" +const TOKEN = "mstat_correct-horse-battery-staple" + +const delayedBanner = (html: string): string => + html.match(/
]*>/u)?.[0] ?? "" +const entry = (fields: Partial = {}): StatusPageOverlayEntry => ({ + id: "e1", + at: FIXTURE_NOW - 120_000, + incidentId: null, + title: "Checkout failing", + status: "investigating", + impact: "critical", + body: "Posted from the edge", + componentIds: [fixtureComponentId(0)], + ...fields, +}) + +describe("serving a page", () => { + let fake: FakeStorage + beforeEach(() => { + fake = makeFakeStorage() + publish(fake, makeSnapshot()) + }) + + it("serves the page from KV with cache and security headers, and warms the colo cache", async () => { + const response = await send(fake, PAGE) + const html = await response.text() + expect(response.status).toBe(200) + expect(html).toContain("Acme status") + expect(response.headers.get("cache-control")).toBe( + "public, max-age=15, stale-while-revalidate=60, stale-if-error=86400", + ) + expect(response.headers.get("x-content-type-options")).toBe("nosniff") + const nonce = response.headers + .get("content-security-policy") + ?.match(/script-src 'nonce-([^']+)'/u)?.[1] + expect(nonce).toBeDefined() + expect(html).toContain(``, + ``, + ].join("") +} diff --git a/packages/status-page/src/escape.ts b/packages/status-page/src/escape.ts new file mode 100644 index 0000000000..28b6557840 --- /dev/null +++ b/packages/status-page/src/escape.ts @@ -0,0 +1,24 @@ +/** Escapes text for HTML element content and quoted attributes alike. */ +export const escapeHtml = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") + +/** XML 1.0 forbids most control characters even when escaped, so they are dropped. */ +export const escapeXml = (value: string): string => + // oxlint-disable-next-line no-control-regex + escapeHtml(value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/gu, "")) + +/** A customer URL usable as a link or image: only absolute http(s), never `javascript:`. */ +export const safeUrl = (value: string | null, protocols: ReadonlyArray): string | null => { + if (value === null || !URL.canParse(value)) return null + const url = new URL(value) + return protocols.includes(url.protocol) ? url.toString() : null +} + +/** Only plain hex colours reach the stylesheet. */ +export const safeColor = (value: string | null): string | null => + value !== null && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu.test(value) ? value : null diff --git a/packages/status-page/src/feed.ts b/packages/status-page/src/feed.ts new file mode 100644 index 0000000000..934ab1f8cd --- /dev/null +++ b/packages/status-page/src/feed.ts @@ -0,0 +1,21 @@ +import type { StatusPageSnapshot } from "@maple/domain/status-page" +import { escapeXml } from "./escape" +import { INCIDENT_STATUS_LABEL } from "./format" +import { latestActivityAt } from "./select" + +const iso = (ms: number): string => new Date(ms).toISOString() + +/** Atom feed with one entry per incident update, newest first. `pageUrl` is the page's absolute URL. */ +export const renderAtomFeed = (snapshot: StatusPageSnapshot, pageUrl: string): string => { + // Callers pass an origin plus base path; one trailing slash at most, trimmed without a regex. + const base = pageUrl.endsWith("/") ? pageUrl.slice(0, -1) : pageUrl + const entries = snapshot.incidents + .flatMap((incident) => incident.updates.map((update) => ({ incident, update }))) + .sort((a, b) => b.update.at - a.update.at) + .map( + ({ incident, update }) => + `urn:maple:status:${escapeXml(snapshot.pageId)}:${escapeXml(incident.id)}:${escapeXml(update.id)}${escapeXml(`${incident.title}: ${INCIDENT_STATUS_LABEL[update.status]}`)}${iso(update.at)}${iso(update.at)}${escapeXml(update.body)}`, + ) + .join("") + return `urn:maple:status:${escapeXml(snapshot.pageId)}${escapeXml(`${snapshot.page.name} status`)}${iso(latestActivityAt(snapshot))}${escapeXml(snapshot.page.name)}${entries}` +} diff --git a/packages/status-page/src/fixtures.ts b/packages/status-page/src/fixtures.ts new file mode 100644 index 0000000000..703dc4ead5 --- /dev/null +++ b/packages/status-page/src/fixtures.ts @@ -0,0 +1,99 @@ +import { Schema } from "effect" +import { + lastDays, + StatusPageSnapshot, + STATUS_PAGE_HISTORY_DAYS, + type StatusPageDayState, +} from "@maple/domain/status-page" + +// Snapshot builders for tests and sample renders. Values are decoded, so brands are real. + +type Encoded = typeof StatusPageSnapshot.Encoded +type EncodedComponent = Encoded["components"][number] +type EncodedIncident = Encoded["incidents"][number] +type EncodedDay = EncodedComponent["days"][number] + +export const FIXTURE_PAGE_ID = "0b4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1a01" +export const FIXTURE_NOW = Date.UTC(2026, 9, 28, 12, 0, 0) + +const COMPONENT_IDS = [ + "1c4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1a01", + "1c4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1a02", + "1c4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1a03", + "1c4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1a04", +] as const + +export const fixtureComponentId = (index: number): string => + COMPONENT_IDS[index] ?? "1c4c9b8e-6f2a-4d1e-9a5b-2f6c8d7e1aff" + +const DAY_COUNTS = { + none: { checks: 0, failures: 0, downChecks: 0 }, + ok: { checks: 1440, failures: 0, downChecks: 0 }, + degraded: { checks: 1440, failures: 3, downChecks: 0 }, + down: { checks: 1440, failures: 30, downChecks: 30 }, +} as const satisfies Record> + +/** 90 days ending on `nowMs`, all `ok` except the given overrides (index 0 is the oldest). */ +export const makeDays = ( + nowMs: number = FIXTURE_NOW, + overrides: Readonly> = {}, +): ReadonlyArray => + lastDays(nowMs, STATUS_PAGE_HISTORY_DAYS).map((day, index) => ({ + day, + ...DAY_COUNTS[overrides[index] ?? "ok"], + })) + +export const makeComponent = (index: number, fields: Partial = {}): EncodedComponent => ({ + id: fixtureComponentId(index), + name: `Component ${index + 1}`, + description: null, + group: null, + status: "operational", + // A fixture's status is its base unless a test says otherwise. + baseStatus: fields.status ?? "operational", + showUptime: true, + monitored: true, + days: makeDays(), + ...fields, +}) + +export const makeIncident = (id: string, fields: Partial = {}): EncodedIncident => ({ + id, + kind: "incident", + title: `Incident ${id}`, + impact: "minor", + status: "investigating", + startedAt: FIXTURE_NOW - 30 * 60_000, + resolvedAt: null, + scheduledStart: null, + scheduledEnd: null, + componentIds: [], + openedByOverlayEntryId: null, + updates: [], + ...fields, +}) + +export const makeSnapshot = (fields: Partial = {}): StatusPageSnapshot => + Schema.decodeUnknownSync(StatusPageSnapshot)({ + schemaVersion: 1, + pageId: FIXTURE_PAGE_ID, + revision: 1, + generatedAt: FIXTURE_NOW - 60_000, + page: { + name: "Acme", + slug: "acme", + description: "Status of Acme services", + websiteUrl: "https://acme.example", + logoUrl: null, + brandColor: null, + showPoweredBy: true, + }, + breakGlassTokenSha256: null, + components: [ + makeComponent(0, { name: "API", group: "Platform" }), + makeComponent(1, { name: "Dashboard", group: "Platform" }), + makeComponent(2, { name: "Docs", showUptime: false, monitored: false, days: [] }), + ], + incidents: [], + ...fields, + }) diff --git a/packages/status-page/src/format.test.ts b/packages/status-page/src/format.test.ts new file mode 100644 index 0000000000..8476e6d1de --- /dev/null +++ b/packages/status-page/src/format.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest" +import { formatPercent } from "./format" + +describe("formatPercent", () => { + it("prints an exact two-decimal uptime that float error puts just below it", () => { + // 624 of 625 checks up: 99.84, which `* 100` makes 9983.999999999999. + expect(formatPercent((624 / 625) * 100)).toBe("99.84%") + }) + + it("still floors real downtime, so it never rounds up to 100%", () => { + expect(formatPercent(99.999)).toBe("99.99%") + expect(formatPercent(100)).toBe("100%") + }) +}) diff --git a/packages/status-page/src/format.ts b/packages/status-page/src/format.ts new file mode 100644 index 0000000000..5260121848 --- /dev/null +++ b/packages/status-page/src/format.ts @@ -0,0 +1,98 @@ +import type { + StatusPageComponentStatus, + StatusPageIncidentImpact, + StatusPageIncidentStatus, +} from "@maple/domain/primitives" +import type { StatusPageDayBar, StatusPageDayState } from "@maple/domain/status-page" + +export const COMPONENT_STATUS_LABEL = { + operational: "Operational", + maintenance: "Under maintenance", + degraded: "Degraded performance", + partial_outage: "Partial outage", + major_outage: "Major outage", +} as const satisfies Record + +export const OVERALL_STATUS_LABEL = { + operational: "All systems operational", + maintenance: "Maintenance in progress", + degraded: "Degraded performance", + partial_outage: "Partial system outage", + major_outage: "Major system outage", +} as const satisfies Record + +export const INCIDENT_STATUS_LABEL = { + investigating: "Investigating", + identified: "Identified", + monitoring: "Monitoring", + resolved: "Resolved", + scheduled: "Scheduled", + in_progress: "In progress", + completed: "Completed", +} as const satisfies Record + +export const IMPACT_LABEL = { + none: "No impact", + minor: "Minor", + major: "Major", + critical: "Critical", +} as const satisfies Record + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + +const pad = (value: number): string => String(value).padStart(2, "0") + +/** `Oct 28, 2026`, UTC: rendering at the edge must not depend on the colo's locale. */ +export const formatDate = (ms: number): string => { + const date = new Date(ms) + return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}` +} + +/** `Oct 28, 2026, 14:05 UTC`. */ +export const formatDateTime = (ms: number): string => { + const date = new Date(ms) + return `${formatDate(ms)}, ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())} UTC` +} + +/** `YYYY-MM-DD` day of a bar, as `Oct 28, 2026`. */ +export const formatDay = (day: string): string => { + const [year = 1970, month = 1, date = 1] = day.split("-").map(Number) + return formatDate(Date.UTC(year, month - 1, date)) +} + +/** `just now`, `4 min ago`, `3 h ago`, `2 d ago`. The inline page script mirrors this. */ +export const formatAge = (ageMs: number): string => { + const minutes = Math.floor(Math.max(0, ageMs) / 60_000) + if (minutes < 1) return "just now" + if (minutes < 60) return `${minutes} min ago` + const hours = Math.floor(minutes / 60) + if (hours < 48) return `${hours} h ago` + return `${Math.floor(hours / 24)} d ago` +} + +/** Floors so a day with any downtime never reads as 100%. */ +export const formatPercent = (percent: number): string => { + // Rounds off float error first: 99.84 must not floor from 99.83999999999999. + const floored = Math.floor(Number((percent * 100).toFixed(6))) / 100 + return `${floored.toFixed(2).replace(/\.?0+$/u, "")}%` +} + +export const DAY_STATE_LABEL = { + none: "No data", + ok: "No downtime", + degraded: "Some checks failed", + down: "Downtime recorded", +} as const satisfies Record + +/** The tooltip of one bar: the day and what happened on it. */ +export const dayTitle = (bar: StatusPageDayBar, state: StatusPageDayState): string => { + const detail = + state === "none" + ? DAY_STATE_LABEL.none + : state === "ok" + ? `${DAY_STATE_LABEL.ok}, ${bar.checks} checks` + : state === "degraded" + ? `${bar.failures} of ${bar.checks} checks failed` + : `Down during ${bar.downChecks} of ${bar.checks} checks` + return `${formatDay(bar.day)}: ${detail}` +} diff --git a/packages/status-page/src/index.ts b/packages/status-page/src/index.ts new file mode 100644 index 0000000000..a4f328901c --- /dev/null +++ b/packages/status-page/src/index.ts @@ -0,0 +1,7 @@ +export { renderBadgeSvg } from "./badge" +export type { RenderOptions } from "./document" +export { escapeHtml, escapeXml } from "./escape" +export { renderAtomFeed } from "./feed" +export { renderStatusJson, STATUS_INDICATOR } from "./json" +export { MANAGE_FIELDS, type ManageMessage, NEW_INCIDENT, NO_CHANGE, renderManagePage } from "./manage" +export { PAGE_SCRIPT, renderPage } from "./page" diff --git a/packages/status-page/src/json.ts b/packages/status-page/src/json.ts new file mode 100644 index 0000000000..f89ed24f10 --- /dev/null +++ b/packages/status-page/src/json.ts @@ -0,0 +1,76 @@ +import type { StatusPageComponentStatus } from "@maple/domain/primitives" +import { + overallStatus, + type StatusPageFreshness, + type StatusPageSnapshot, + type StatusPageSnapshotIncident, +} from "@maple/domain/status-page" +import { activeIncidents } from "./select" + +/** statuspage.io's page indicator for the overall status. */ +export const STATUS_INDICATOR = { + operational: { indicator: "none", description: "All Systems Operational" }, + maintenance: { indicator: "maintenance", description: "Service Under Maintenance" }, + degraded: { indicator: "minor", description: "Partially Degraded Service" }, + partial_outage: { indicator: "major", description: "Partial System Outage" }, + major_outage: { indicator: "critical", description: "Major System Outage" }, +} as const satisfies Record + +const COMPONENT_STATUS = { + operational: "operational", + maintenance: "under_maintenance", + degraded: "degraded_performance", + partial_outage: "partial_outage", + major_outage: "major_outage", +} as const satisfies Record + +const iso = (ms: number | null): string | null => (ms === null ? null : new Date(ms).toISOString()) + +const incidentJson = (incident: StatusPageSnapshotIncident) => ({ + id: incident.id, + name: incident.title, + status: incident.status, + impact: incident.impact, + created_at: iso(incident.startedAt), + updated_at: iso(incident.updates[0]?.at ?? incident.startedAt), + resolved_at: iso(incident.resolvedAt), + component_ids: incident.componentIds, + incident_updates: incident.updates.map((update) => ({ + id: update.id, + status: update.status, + body: update.body, + created_at: iso(update.at), + })), +}) + +/** A statuspage.io-style `summary.json`, plus whether live checks are delayed. */ +export const renderStatusJson = (snapshot: StatusPageSnapshot, freshness: StatusPageFreshness) => { + const overall = overallStatus(snapshot) + const active = activeIncidents(snapshot) + return { + page: { + id: snapshot.pageId, + name: snapshot.page.name, + url: snapshot.page.websiteUrl, + time_zone: "Etc/UTC", + updated_at: iso(freshness.updatedAt), + }, + status: { ...STATUS_INDICATOR[overall], delayed: freshness._tag === "delayed" }, + components: snapshot.components.map((component) => ({ + id: component.id, + name: component.name, + description: component.description, + group: component.group, + status: COMPONENT_STATUS[component.status], + })), + incidents: active.filter((incident) => incident.kind === "incident").map(incidentJson), + scheduled_maintenances: snapshot.incidents + .filter((incident) => incident.kind === "maintenance" && incident.status !== "completed") + .map((incident) => ({ + ...incidentJson(incident), + scheduled_for: iso(incident.scheduledStart), + scheduled_until: iso(incident.scheduledEnd), + })), + updated_at: iso(freshness.updatedAt), + } +} diff --git a/packages/status-page/src/manage.ts b/packages/status-page/src/manage.ts new file mode 100644 index 0000000000..6c2168be43 --- /dev/null +++ b/packages/status-page/src/manage.ts @@ -0,0 +1,101 @@ +import { + StatusPageIncidentImpact, + type StatusPageIncidentKind, + StatusPageIncidentStatus, +} from "@maple/domain/primitives" +import { type StatusPageSnapshot, statusFitsKind } from "@maple/domain/status-page" +import { pageHref, type RenderOptions, renderDocument, renderHeader } from "./document" +import { escapeHtml } from "./escape" +import { IMPACT_LABEL, INCIDENT_STATUS_LABEL } from "./format" +import { activeIncidents, upcomingMaintenance } from "./select" +import { manageStyles } from "./styles" + +/** Form field names, shared with the Worker that parses the POST. */ +export const MANAGE_FIELDS = { + token: "token", + incidentId: "incidentId", + title: "title", + status: "status", + impact: "impact", + componentIds: "componentIds", + body: "body", +} as const + +/** `incidentId` value that opens a new incident. */ +export const NEW_INCIDENT = "new" + +/** Status and impact value that keeps the incident's current one; a new incident takes the defaults. */ +export const NO_CHANGE = "" + +export interface ManageMessage { + readonly tone: "ok" | "error" + readonly text: string +} + +const option = (value: string, label: string, selected = false): string => + `` + +/** A target option carrying its kind, so the status select can follow it. */ +const targetOption = (value: string, label: string, kind: StatusPageIncidentKind): string => + `` + +/** + * Disables the status group the chosen target cannot take, and clears a choice from it. Without + * script the server still answers a mismatch with a 400 that names the right group. + */ +const followTargetKind = (targetField: string, statusField: string): string => + `(()=>{const t=document.querySelector('select[name="${targetField}"]'),s=document.querySelector('select[name="${statusField}"]');if(!t||!s)return;const sync=()=>{const k=t.selectedOptions[0]?.dataset.kind??"incident";s.querySelectorAll("optgroup").forEach((g)=>{g.disabled=g.dataset.kind!==k});if(s.selectedOptions[0]?.parentElement?.disabled)s.value=""};t.addEventListener("change",sync);sync()})()` + +/** Statuses grouped by lifecycle: posting one to the other kind is rejected, so the form says which is which. */ +const statusGroup = (kind: StatusPageIncidentKind, label: string): string => + `${StatusPageIncidentStatus.literals + .filter((status) => statusFitsKind(kind, status)) + .map((status) => option(status, INCIDENT_STATUS_LABEL[status])) + .join("")}` + +/** Break-glass form: post an incident update straight to the edge while Maple is down. */ +export const renderManagePage = ( + snapshot: StatusPageSnapshot, + message?: ManageMessage, + options: RenderOptions = {}, +): string => { + const f = MANAGE_FIELDS + const incidents = [...activeIncidents(snapshot), ...upcomingMaintenance(snapshot)] + const notice = + message === undefined + ? "" + : `
${escapeHtml(message.text)}
` + const components = + snapshot.components.length === 0 + ? "" + : `
Affected components${snapshot.components + .map( + (component) => + ``, + ) + .join("")}
` + const body = [ + renderHeader(snapshot, options), + notice, + `

Post an update

For use while Maple is unreachable. The update shows on the page at once and is imported into Maple when it recovers.

`, + `
`, + ``, + ``, + ``, + ``, + ``, + components, + ``, + `
`, + ``, + ].join("") + return renderDocument({ + snapshot, + options, + title: `Post an update: ${snapshot.page.name}`, + styles: manageStyles, + body, + head: ``, + script: followTargetKind(f.incidentId, f.status), + }) +} diff --git a/packages/status-page/src/page.ts b/packages/status-page/src/page.ts new file mode 100644 index 0000000000..6f34ae9da1 --- /dev/null +++ b/packages/status-page/src/page.ts @@ -0,0 +1,144 @@ +import { + dayState, + incidentComponentStatus, + overallStatus, + STATUS_PAGE_STALE_AFTER_MS, + type StatusPageFreshness, + type StatusPageSnapshot, + type StatusPageSnapshotComponent, + type StatusPageSnapshotIncident, + uptimePercent, +} from "@maple/domain/status-page" +import { pageHref, type RenderOptions, renderDocument, renderHeader } from "./document" +import { escapeHtml } from "./escape" +import { + COMPONENT_STATUS_LABEL, + dayTitle, + formatAge, + formatDateTime, + INCIDENT_STATUS_LABEL, + OVERALL_STATUS_LABEL, + formatPercent, +} from "./format" +import { activeIncidents, groupComponents, pastIncidents, upcomingMaintenance } from "./select" +import { pageStyles } from "./styles" + +/** Recomputes "Updated ..." and reveals the delayed banner, so a cached copy ages honestly. */ +export const PAGE_SCRIPT = `(function(){var b=document.body,u=+b.getAttribute("data-updated-at"),s=+b.getAttribute("data-stale-after");function a(ms){var m=Math.floor(Math.max(0,ms)/6e4);if(m<1)return"just now";if(m<60)return m+" min ago";var h=Math.floor(m/60);return h<48?h+" h ago":Math.floor(h/24)+" d ago"}function t(){var g=Date.now()-u,e=document.getElementById("updated"),d=document.getElementById("delayed");if(e)e.textContent="Updated "+a(g);if(d&&g>s)d.hidden=false}t();setInterval(t,3e4)})();` + +const timeTag = (ms: number): string => + `` + +const renderUpdates = (incident: StatusPageSnapshotIncident): string => + incident.updates.length === 0 + ? "" + : `
    ${incident.updates + .map( + (update) => + `
  1. ${INCIDENT_STATUS_LABEL[update.status]}

    ${escapeHtml(update.body)}

    ${timeTag(update.at)}
  2. `, + ) + .join("")}
` + +const incidentMeta = (incident: StatusPageSnapshotIncident): string => { + if (incident.kind === "maintenance" && incident.scheduledStart !== null) { + const end = incident.scheduledEnd === null ? "" : ` to ${timeTag(incident.scheduledEnd)}` + return `Scheduled for ${timeTag(incident.scheduledStart)}${end}` + } + if (incident.resolvedAt !== null) { + return `${timeTag(incident.startedAt)} to ${timeTag(incident.resolvedAt)}` + } + return `Started ${timeTag(incident.startedAt)}` +} + +const renderIncident = (incident: StatusPageSnapshotIncident, withUpdates: boolean): string => + `

${escapeHtml(incident.title)}

${INCIDENT_STATUS_LABEL[incident.status]} · ${incidentMeta(incident)}
${withUpdates ? renderUpdates(incident) : ""}
` + +const renderBars = (component: StatusPageSnapshotComponent): string => { + if (!component.showUptime || component.days.length === 0) return "" + const percent = uptimePercent(component.days) + const bars = component.days + .map((bar) => { + const state = dayState(bar) + return `
  • ` + }) + .join("") + const uptime = percent === null ? "No checks yet" : `${formatPercent(percent)} uptime` + return `
      ${bars}
    ${component.days.length} days ago${uptime}Today
    ` +} + +const renderComponent = (component: StatusPageSnapshotComponent): string => { + const description = + component.description === null ? "" : `

    ${escapeHtml(component.description)}

    ` + return `
    ${escapeHtml(component.name)}${COMPONENT_STATUS_LABEL[component.status]}
    ${description}${renderBars(component)}
    ` +} + +const renderComponents = (snapshot: StatusPageSnapshot): string => { + if (snapshot.components.length === 0) return "" + const groups = groupComponents(snapshot.components) + .map((group) => { + const head = + group.name === null + ? "" + : `
    ${escapeHtml(group.name)}${COMPONENT_STATUS_LABEL[group.status]}
    ` + return `
    ${head}${group.components.map(renderComponent).join("")}
    ` + }) + .join("") + return `

    Components

    ${groups}
    ` +} + +const renderSection = (id: string, title: string, inner: string): string => + `

    ${title}

    ${inner}
    ` + +/** The public status page: server-rendered, self-contained, readable with scripts off. */ +export const renderPage = ( + snapshot: StatusPageSnapshot, + freshness: StatusPageFreshness, + nowMs: number, + options: RenderOptions = {}, +): string => { + const overall = overallStatus(snapshot) + const active = activeIncidents(snapshot) + const upcoming = upcomingMaintenance(snapshot) + const past = pastIncidents(snapshot) + const delayed = freshness._tag === "delayed" + + const body = [ + renderHeader(snapshot, options), + `
    Live checks are delayed. Showing the last known state from ${timeTag(freshness.updatedAt)}.
    `, + ``, + active.length === 0 + ? "" + : renderSection( + "active", + "Active incidents", + active.map((i) => renderIncident(i, true)).join(""), + ), + upcoming.length === 0 + ? "" + : renderSection( + "upcoming", + "Upcoming maintenance", + upcoming.map((i) => renderIncident(i, true)).join(""), + ), + renderComponents(snapshot), + renderSection( + "past", + "Past incidents", + past.length === 0 + ? `

    No incidents reported recently.

    ` + : past.map((i) => renderIncident(i, true)).join(""), + ), + `
    Updated ${formatAge(nowMs - freshness.updatedAt)}Atom feed${snapshot.page.showPoweredBy ? ` · Powered by Maple` : ""}
    `, + ].join("") + + return renderDocument({ + snapshot, + options, + title: `${snapshot.page.name} status`, + styles: pageStyles, + body, + bodyAttrs: ` data-generated-at="${snapshot.generatedAt}" data-updated-at="${freshness.updatedAt}" data-stale-after="${STATUS_PAGE_STALE_AFTER_MS}" data-freshness="${freshness._tag}"`, + head: ``, + script: PAGE_SCRIPT, + }) +} diff --git a/packages/status-page/src/render.test.ts b/packages/status-page/src/render.test.ts new file mode 100644 index 0000000000..ed0e01d4b3 --- /dev/null +++ b/packages/status-page/src/render.test.ts @@ -0,0 +1,275 @@ +import type { StatusPageComponentStatus } from "@maple/domain/primitives" +import type { StatusPageFreshness } from "@maple/domain/status-page" +import { describe, expect, it } from "vitest" +import { + FIXTURE_NOW, + fixtureComponentId, + makeComponent, + makeDays, + makeIncident, + makeSnapshot, +} from "./fixtures" +import { renderAtomFeed, renderBadgeSvg, renderManagePage, renderPage, renderStatusJson } from "./index" + +const HOSTILE = `&'` +const live: StatusPageFreshness = { _tag: "live", updatedAt: FIXTURE_NOW - 2 * 60_000 } +const delayed: StatusPageFreshness = { _tag: "delayed", updatedAt: Date.UTC(2026, 9, 28, 11, 0) } + +const update = ( + id: string, + at: number, + body: string, + status: "investigating" | "resolved" = "investigating", +) => ({ + id, + status, + body, + at, + source: "dashboard" as const, + overlayEntryId: null, +}) + +const hostileSnapshot = makeSnapshot({ + page: { + name: HOSTILE, + slug: "acme", + description: HOSTILE, + websiteUrl: "javascript:alert(1)", + logoUrl: "http://insecure.example/logo.png", + brandColor: "red;}body{display:none", + showPoweredBy: false, + }, + components: [makeComponent(0, { name: HOSTILE, group: HOSTILE, description: HOSTILE })], + incidents: [ + makeIncident("inc-1", { + title: HOSTILE, + updates: [update("u-1", FIXTURE_NOW - 60_000, HOSTILE)], + }), + ], +}) + +/** The `
  • ` bars of one component, in order. */ +const barsOf = (html: string, componentId: string) => { + const block = html.split(`data-component="${componentId}"`)[1]?.split(`data-component="`)[0] ?? "" + return [ + ...block.matchAll(/
  • /gu), + ].map((match) => ({ state: match[1], day: match[2], title: match[3] })) +} + +const delayedBanner = (html: string): string => + html.match(/
    ]*>/u)?.[0] ?? "" + +describe("renderPage", () => { + it("escapes every piece of customer text and drops unsafe URLs and colours", () => { + const html = renderPage(hostileSnapshot, live, FIXTURE_NOW) + expect(html).not.toContain(`