From 5bf02b47d5c0f5e187da67a1a7f220d8cf1adbf7 Mon Sep 17 00:00:00 2001 From: nate Date: Wed, 8 Apr 2026 15:26:17 +0400 Subject: [PATCH] refactor tier 3 --- apps/api/src/index.ts | 6 + apps/api/src/jobs/rollup.ts | 92 +++++++++ apps/api/src/routes/incidents.ts | 186 +++++++++++++++++ apps/api/src/routes/monitors.ts | 30 ++- apps/api/src/routes/status_pages.ts | 212 +++++++++++++++++++ apps/shared/db.ts | 119 +++++++++++ apps/shared/render/sparkline.ts | 52 +++++ apps/shared/render/time.ts | 13 ++ apps/status/package.json | 17 ++ apps/status/src/auth.ts | 47 +++++ apps/status/src/cache.ts | 38 ++++ apps/status/src/data.ts | 260 ++++++++++++++++++++++++ apps/status/src/db.ts | 11 + apps/status/src/index.ts | 172 ++++++++++++++++ apps/status/src/rate-limit.ts | 31 +++ apps/status/src/render/badge.ts | 33 +++ apps/status/src/render/rss.ts | 62 ++++++ apps/status/src/views/not-found.ejs | 4 + apps/status/src/views/page.ejs | 223 ++++++++++++++++++++ apps/status/src/views/password.ejs | 31 +++ apps/status/tsconfig.json | 16 ++ apps/web/src/routes/dashboard.ts | 232 +++++++++++++++++++++ apps/web/src/utils/sparkline.ts | 53 +---- apps/web/src/views/docs.ejs | 33 +++ apps/web/src/views/incident-edit.ejs | 121 +++++++++++ apps/web/src/views/incidents.ejs | 46 +++++ apps/web/src/views/partials/nav.ejs | 2 + apps/web/src/views/status-page-edit.ejs | 109 ++++++++++ apps/web/src/views/status-pages.ejs | 42 ++++ deploy.sh | 28 ++- 30 files changed, 2263 insertions(+), 58 deletions(-) create mode 100644 apps/api/src/jobs/rollup.ts create mode 100644 apps/api/src/routes/incidents.ts create mode 100644 apps/api/src/routes/status_pages.ts create mode 100644 apps/shared/render/sparkline.ts create mode 100644 apps/shared/render/time.ts create mode 100644 apps/status/package.json create mode 100644 apps/status/src/auth.ts create mode 100644 apps/status/src/cache.ts create mode 100644 apps/status/src/data.ts create mode 100644 apps/status/src/db.ts create mode 100644 apps/status/src/index.ts create mode 100644 apps/status/src/rate-limit.ts create mode 100644 apps/status/src/render/badge.ts create mode 100644 apps/status/src/render/rss.ts create mode 100644 apps/status/src/views/not-found.ejs create mode 100644 apps/status/src/views/page.ejs create mode 100644 apps/status/src/views/password.ejs create mode 100644 apps/status/tsconfig.json create mode 100644 apps/web/src/views/incident-edit.ejs create mode 100644 apps/web/src/views/incidents.ejs create mode 100644 apps/web/src/views/status-page-edit.ejs create mode 100644 apps/web/src/views/status-pages.ejs diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 237ae64..82cd58c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,6 +4,9 @@ import { monitors } from "./routes/monitors"; import { account } from "./routes/auth"; import { internal } from "./routes/internal"; import { channels } from "./routes/channels"; +import { statusPages } from "./routes/status_pages"; +import { incidents } from "./routes/incidents"; +import { startRollupJob } from "./jobs/rollup"; import { migrate } from "./db"; import { SECURITY_HEADERS } from "../../shared/auth"; @@ -17,6 +20,7 @@ process.on("uncaughtException", (err) => { }); await migrate(); +await startRollupJob(); const elysia = new Elysia() .get("/", () => ({ @@ -27,6 +31,8 @@ const elysia = new Elysia() .use(account) .use(monitors) .use(channels) + .use(statusPages) + .use(incidents) .use(ingest) .use(internal); diff --git a/apps/api/src/jobs/rollup.ts b/apps/api/src/jobs/rollup.ts new file mode 100644 index 0000000..db96872 --- /dev/null +++ b/apps/api/src/jobs/rollup.ts @@ -0,0 +1,92 @@ +import sql from "../db"; + +// Aggregates raw pings into monitor_uptime_rollup so status pages and dashboard +// widgets can compute uptime % over arbitrary windows without ever scanning the +// pings table at read time. Three resolutions: hourly, daily, weekly. +// +// Each pass aggregates the *current* bucket only. The query is bounded by the +// bucket size, not the table size, so it's cheap regardless of history depth. + +type BucketType = "hourly" | "daily" | "weekly"; + +const BUCKET_TRUNC: Record = { + hourly: "hour", + daily: "day", + weekly: "week", +}; + +async function rollupCurrent(bucket: BucketType): Promise { + const trunc = BUCKET_TRUNC[bucket]; + // Aggregate the bucket containing now(). ON CONFLICT updates if the row exists, + // so this is safe to run repeatedly during the bucket's lifetime. + const result = await sql` + INSERT INTO monitor_uptime_rollup (monitor_id, region, bucket_type, bucket_start, total, up_count, avg_latency) + SELECT + monitor_id, + COALESCE(region, 'default') AS region, + ${bucket} AS bucket_type, + date_trunc(${trunc}, checked_at) AS bucket_start, + count(*)::int AS total, + count(*) FILTER (WHERE up)::int AS up_count, + avg(latency_ms)::real AS avg_latency + FROM pings + WHERE checked_at >= date_trunc(${trunc}, now()) + GROUP BY monitor_id, COALESCE(region, 'default'), date_trunc(${trunc}, checked_at) + ON CONFLICT (monitor_id, region, bucket_type, bucket_start) DO UPDATE SET + total = EXCLUDED.total, + up_count = EXCLUDED.up_count, + avg_latency = EXCLUDED.avg_latency + `; + return result.count ?? 0; +} + +// Walk back N units and aggregate any buckets that don't exist yet. Used at +// startup so a freshly-deployed system has historical data immediately. +async function backfillRecent(bucket: BucketType, units: number): Promise { + const trunc = BUCKET_TRUNC[bucket]; + // Build the interval string entirely in JS so postgres.js binds a single text + // parameter. Avoids the int || text type-mismatch trap inside SQL. + const intervalLiteral = `${units} ${trunc}s`; + const result = await sql` + INSERT INTO monitor_uptime_rollup (monitor_id, region, bucket_type, bucket_start, total, up_count, avg_latency) + SELECT + monitor_id, + COALESCE(region, 'default') AS region, + ${bucket} AS bucket_type, + date_trunc(${trunc}, checked_at) AS bucket_start, + count(*)::int AS total, + count(*) FILTER (WHERE up)::int AS up_count, + avg(latency_ms)::real AS avg_latency + FROM pings + WHERE checked_at >= date_trunc(${trunc}, now()) - ${intervalLiteral}::interval + GROUP BY monitor_id, COALESCE(region, 'default'), date_trunc(${trunc}, checked_at) + ON CONFLICT (monitor_id, region, bucket_type, bucket_start) DO NOTHING + `; + return result.count ?? 0; +} + +let started = false; + +export async function startRollupJob() { + if (started) return; + started = true; + + // Startup backfill: gives existing accounts immediate history without waiting + // for the periodic timers to wander backwards. Cheap because pings is indexed + // on checked_at and the units are bounded. + try { + const [h, d, w] = await Promise.all([ + backfillRecent("hourly", 48), // 48h of hourly buckets + backfillRecent("daily", 90), // 90 days of daily buckets + backfillRecent("weekly", 26), // 26 weeks of weekly buckets + ]); + console.log(`[rollup] backfilled rows: hourly=${h} daily=${d} weekly=${w}`); + } catch (e) { + console.warn("[rollup] backfill failed:", e); + } + + // Periodic refreshes for the *current* bucket of each resolution. + setInterval(() => { rollupCurrent("hourly").catch((e) => console.warn("[rollup] hourly failed:", e)); }, 5 * 60 * 1000); + setInterval(() => { rollupCurrent("daily").catch((e) => console.warn("[rollup] daily failed:", e)); }, 30 * 60 * 1000); + setInterval(() => { rollupCurrent("weekly").catch((e) => console.warn("[rollup] weekly failed:", e)); }, 6 * 60 * 60 * 1000); +} diff --git a/apps/api/src/routes/incidents.ts b/apps/api/src/routes/incidents.ts new file mode 100644 index 0000000..fe90171 --- /dev/null +++ b/apps/api/src/routes/incidents.ts @@ -0,0 +1,186 @@ +import { Elysia, t } from "elysia"; +import { requireAuth } from "./auth"; +import sql from "../db"; + +const Status = t.Union([t.Literal("investigating"), t.Literal("identified"), t.Literal("monitoring"), t.Literal("resolved")]); +const Severity = t.Union([t.Literal("minor"), t.Literal("major"), t.Literal("critical")]); + +const IncidentBody = t.Object({ + title: t.String({ minLength: 1, maxLength: 200 }), + status: Status, + severity: t.Optional(Severity), + pinned: t.Optional(t.Boolean()), + monitor_ids: t.Optional(t.Array(t.String())), + status_page_ids: t.Optional(t.Array(t.String())), + initial_update: t.Optional(t.Object({ + body: t.String({ minLength: 1, maxLength: 10_000 }), + })), +}); + +const IncidentUpdateBody = t.Object({ + status: Status, + body: t.String({ minLength: 1, maxLength: 10_000 }), +}); + +// HTML escape every byte first, then walk a tiny markdown subset and produce +// safe HTML. Anything we didn't explicitly enable stays escaped. Output is +// stored in incident_updates.body_html and rendered without further processing. +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +function renderMarkdown(src: string): string { + let out = escapeHtml(src.trim()); + // Inline code first so we don't expand markdown inside it. + const codeStash: string[] = []; + out = out.replace(/`([^`\n]+?)`/g, (_m, code) => { + codeStash.push(code); + return `\u0000${codeStash.length - 1}\u0000`; + }); + // Links: [text](http(s)://...) + out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, text, url) => + `${text}`, + ); + // Bold then italic. + out = out.replace(/\*\*([^*\n]+?)\*\*/g, "$1"); + out = out.replace(/\*([^*\n]+?)\*/g, "$1"); + // Restore inline code as . + out = out.replace(/\u0000(\d+)\u0000/g, (_m, i) => `${codeStash[Number(i)]}`); + // Paragraphs: split on blank lines, single newlines become
. + const paras = out.split(/\n{2,}/).map((p) => `

${p.replace(/\n/g, "
")}

`); + return paras.join(""); +} + +async function attachJoins(incidentId: string, accountId: string, monitorIds: string[] | undefined, pageIds: string[] | undefined) { + if (monitorIds !== undefined) { + await sql`DELETE FROM incident_monitors WHERE incident_id = ${incidentId}`; + if (monitorIds.length > 0) { + const owned = await sql<{ id: string }[]>` + SELECT id FROM monitors + WHERE account_id = ${accountId} AND id = ANY(${sql.array(monitorIds)}::text[]) + `; + if (owned.length > 0) { + const rows = owned.map((o) => ({ incident_id: incidentId, monitor_id: o.id })); + await sql`INSERT INTO incident_monitors ${sql(rows, "incident_id", "monitor_id")}`; + } + } + } + if (pageIds !== undefined) { + await sql`DELETE FROM incident_status_pages WHERE incident_id = ${incidentId}`; + if (pageIds.length > 0) { + const owned = await sql<{ id: string }[]>` + SELECT id FROM status_pages + WHERE account_id = ${accountId} AND id = ANY(${sql.array(pageIds)}::uuid[]) + `; + if (owned.length > 0) { + const rows = owned.map((o) => ({ incident_id: incidentId, status_page_id: o.id })); + await sql`INSERT INTO incident_status_pages ${sql(rows, "incident_id", "status_page_id")}`; + } + } + } +} + +export const incidents = new Elysia({ prefix: "/incidents" }) + .use(requireAuth) + + .get("/", async ({ accountId }) => { + return sql` + SELECT id, title, status, severity, pinned, started_at, resolved_at, created_at + FROM incidents + WHERE account_id = ${accountId} + ORDER BY started_at DESC + LIMIT 200 + `; + }, { detail: { summary: "List incidents", tags: ["incidents"] } }) + + .post("/", async ({ accountId, body }) => { + const [row] = await sql` + INSERT INTO incidents (account_id, title, status, severity, pinned, resolved_at) + VALUES ( + ${accountId}, ${body.title}, ${body.status}, + ${body.severity ?? 'minor'}, ${body.pinned ?? true}, + ${body.status === 'resolved' ? sql`now()` : null} + ) + RETURNING * + `; + await attachJoins(row.id, accountId, body.monitor_ids, body.status_page_ids); + if (body.initial_update) { + const html = renderMarkdown(body.initial_update.body); + await sql` + INSERT INTO incident_updates (incident_id, status, body, body_html) + VALUES (${row.id}, ${body.status}, ${body.initial_update.body}, ${html}) + `; + } + return row; + }, { body: IncidentBody, detail: { summary: "Create incident", tags: ["incidents"] } }) + + .get("/:id", async ({ accountId, params, set }) => { + const [incident] = await sql` + SELECT * FROM incidents WHERE id = ${params.id} AND account_id = ${accountId} + `; + if (!incident) { set.status = 404; return { error: "Not found" }; } + const updates = await sql` + SELECT id, status, body, body_html, created_at FROM incident_updates + WHERE incident_id = ${params.id} ORDER BY created_at ASC + `; + const monitorRows = await sql<{ monitor_id: string }[]>` + SELECT monitor_id FROM incident_monitors WHERE incident_id = ${params.id} + `; + const pageRows = await sql<{ status_page_id: string }[]>` + SELECT status_page_id FROM incident_status_pages WHERE incident_id = ${params.id} + `; + return { + ...incident, + updates, + monitor_ids: monitorRows.map((r) => r.monitor_id), + status_page_ids: pageRows.map((r) => r.status_page_id), + }; + }, { detail: { summary: "Get incident", tags: ["incidents"] } }) + + .patch("/:id", async ({ accountId, params, body, set }) => { + const [row] = await sql` + UPDATE incidents SET + title = COALESCE(${body.title ?? null}, title), + status = COALESCE(${body.status ?? null}, status), + severity = COALESCE(${body.severity ?? null}, severity), + pinned = COALESCE(${body.pinned ?? null}, pinned), + resolved_at = CASE WHEN ${body.status === 'resolved'} THEN COALESCE(resolved_at, now()) + WHEN ${body.status != null && body.status !== 'resolved'} THEN NULL + ELSE resolved_at END + WHERE id = ${params.id} AND account_id = ${accountId} + RETURNING * + `; + if (!row) { set.status = 404; return { error: "Not found" }; } + await attachJoins(row.id, accountId, body.monitor_ids, body.status_page_ids); + return row; + }, { body: t.Partial(IncidentBody), detail: { summary: "Update incident", tags: ["incidents"] } }) + + .post("/:id/updates", async ({ accountId, params, body, set }) => { + const [incident] = await sql<{ id: string }[]>` + SELECT id FROM incidents WHERE id = ${params.id} AND account_id = ${accountId} + `; + if (!incident) { set.status = 404; return { error: "Not found" }; } + const html = renderMarkdown(body.body); + const [update] = await sql` + INSERT INTO incident_updates (incident_id, status, body, body_html) + VALUES (${params.id}, ${body.status}, ${body.body}, ${html}) + RETURNING * + `; + // Bring the parent incident's status into sync with the latest update. + await sql` + UPDATE incidents SET + status = ${body.status}, + resolved_at = CASE WHEN ${body.status === 'resolved'} THEN COALESCE(resolved_at, now()) ELSE NULL END + WHERE id = ${params.id} + `; + return update; + }, { body: IncidentUpdateBody, detail: { summary: "Post incident update", tags: ["incidents"] } }) + + .delete("/:id", async ({ accountId, params, set }) => { + const [row] = await sql` + DELETE FROM incidents WHERE id = ${params.id} AND account_id = ${accountId} + RETURNING id + `; + if (!row) { set.status = 404; return { error: "Not found" }; } + return { deleted: true }; + }, { detail: { summary: "Delete incident", tags: ["incidents"] } }); diff --git a/apps/api/src/routes/monitors.ts b/apps/api/src/routes/monitors.ts index 1cc4539..7674f6e 100644 --- a/apps/api/src/routes/monitors.ts +++ b/apps/api/src/routes/monitors.ts @@ -19,8 +19,18 @@ const MonitorBody = t.Object({ query: t.Optional(t.Any({ description: "PingQL query — filter conditions for up/down" })), regions: t.Optional(t.Array(t.String(), { description: "Regions to run checks from. Empty array = all regions." })), channel_ids: t.Optional(t.Array(t.String(), { description: "Notification channel IDs to attach to this monitor." })), + tags: t.Optional(t.Array(t.String({ pattern: "^[a-z0-9][a-z0-9-]{0,40}$" }), { description: "Lowercase tag slugs for grouping. Replaces the existing tag set." })), }); +async function replaceMonitorTags(monitorId: string, tags: string[]) { + await sql`DELETE FROM monitor_tags WHERE monitor_id = ${monitorId}`; + if (tags.length === 0) return; + const unique = Array.from(new Set(tags.map((t) => t.trim()).filter(Boolean))); + if (unique.length === 0) return; + const rows = unique.map((tag) => ({ monitor_id: monitorId, tag })); + await sql`INSERT INTO monitor_tags ${sql(rows, "monitor_id", "tag")}`; +} + async function replaceMonitorChannels(monitorId: string, accountId: string, channelIds: string[]) { await sql`DELETE FROM monitor_notifications WHERE monitor_id = ${monitorId}`; if (channelIds.length === 0) return; @@ -39,9 +49,18 @@ async function replaceMonitorChannels(monitorId: string, accountId: string, chan export const monitors = new Elysia({ prefix: "/monitors" }) .use(requireAuth) - .get("/", async ({ accountId }) => { + .get("/", async ({ accountId, query }) => { + const tag = (query as any)?.tag; + if (tag) { + return sql` + SELECT m.* FROM monitors m + JOIN monitor_tags mt ON mt.monitor_id = m.id + WHERE m.account_id = ${accountId} AND mt.tag = ${tag} + ORDER BY m.created_at DESC + `; + } return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`; - }, { detail: { summary: "List monitors", tags: ["monitors"] } }) + }, { detail: { summary: "List monitors (optional ?tag= filter)", tags: ["monitors"] } }) .post("/", async ({ accountId, plan, body, set }) => { const limits = getPlanLimits(plan); @@ -91,6 +110,7 @@ export const monitors = new Elysia({ prefix: "/monitors" }) RETURNING * `; if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids); + if (body.tags) await replaceMonitorTags(monitor.id, body.tags); return monitor; }, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } }) @@ -107,7 +127,10 @@ export const monitors = new Elysia({ prefix: "/monitors" }) const channels = await sql<{ channel_id: string }[]>` SELECT channel_id FROM monitor_notifications WHERE monitor_id = ${params.id} `; - return { ...monitor, results, channel_ids: channels.map((c) => c.channel_id) }; + const tagRows = await sql<{ tag: string }[]>` + SELECT tag FROM monitor_tags WHERE monitor_id = ${params.id} ORDER BY tag + `; + return { ...monitor, results, channel_ids: channels.map((c) => c.channel_id), tags: tagRows.map((t) => t.tag) }; }, { detail: { summary: "Get monitor with results", tags: ["monitors"] } }) .patch("/:id", async ({ accountId, plan, params, body, set }) => { @@ -153,6 +176,7 @@ export const monitors = new Elysia({ prefix: "/monitors" }) `; if (!monitor) { set.status = 404; return { error: "Not found" }; } if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids); + if (body.tags) await replaceMonitorTags(monitor.id, body.tags); return monitor; }, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } }) diff --git a/apps/api/src/routes/status_pages.ts b/apps/api/src/routes/status_pages.ts new file mode 100644 index 0000000..78e1725 --- /dev/null +++ b/apps/api/src/routes/status_pages.ts @@ -0,0 +1,212 @@ +import { Elysia, t } from "elysia"; +import { requireAuth } from "./auth"; +import sql from "../db"; + +const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]); +const Window = t.Union([t.Literal("24h"), t.Literal("7d"), t.Literal("30d"), t.Literal("90d")]); + +const StatusPageBody = t.Object({ + slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }), + title: t.String({ minLength: 1, maxLength: 200 }), + description: t.Optional(t.Nullable(t.String({ maxLength: 2000 }))), + theme: t.Optional(Theme), + password: t.Optional(t.Nullable(t.String({ description: "Plain text. Will be hashed at write time. Pass null to clear." }))), + index_search: t.Optional(t.Boolean()), + show_powered_by: t.Optional(t.Boolean()), + show_response_time: t.Optional(t.Boolean()), + show_cert_expiry: t.Optional(t.Boolean()), + default_window: t.Optional(Window), + custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))), + footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))), + og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))), + analytics_html: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))), + auto_refresh_s: t.Optional(t.Number({ minimum: 10, maximum: 3600 })), + groups: t.Optional(t.Array(t.Object({ + name: t.String({ minLength: 1, maxLength: 200 }), + position: t.Optional(t.Number()), + }))), + monitors: t.Optional(t.Array(t.Object({ + monitor_id: t.String(), + group_index: t.Optional(t.Nullable(t.Number())), + display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))), + position: t.Optional(t.Number()), + }))), +}); + +// Strip @import and expression() from custom CSS — basic sanity, not a full +// parser. The CSS still runs in the visitor's browser; this just blocks the +// most common smuggling vectors. +function sanitizeCss(css: string | null | undefined): string | null { + if (!css) return null; + return css + .replace(/@import[^;]*;?/gi, "") + .replace(/expression\s*\(/gi, ""); +} + +async function hashPassword(plain: string): Promise { + return await Bun.password.hash(plain, { algorithm: "bcrypt", cost: 10 }); +} + +async function replaceGroupsAndMonitors( + pageId: string, + accountId: string, + groups: { name: string; position?: number }[] | undefined, + monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; position?: number }[] | undefined, +) { + if (groups !== undefined) { + await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`; + } + const groupIds: string[] = []; + if (groups && groups.length > 0) { + for (let i = 0; i < groups.length; i++) { + const g = groups[i]!; + const [row] = await sql<{ id: string }[]>` + INSERT INTO status_page_groups (status_page_id, name, position) + VALUES (${pageId}, ${g.name}, ${g.position ?? i}) + RETURNING id + `; + groupIds.push(row!.id); + } + } + + if (monitorsList !== undefined) { + await sql`DELETE FROM status_page_monitors WHERE status_page_id = ${pageId}`; + } + if (monitorsList && monitorsList.length > 0) { + // Validate that the monitors all belong to this account. + const monitorIds = monitorsList.map((m) => m.monitor_id); + const owned = await sql<{ id: string }[]>` + SELECT id FROM monitors + WHERE account_id = ${accountId} AND id = ANY(${sql.array(monitorIds)}::text[]) + `; + const ownedSet = new Set(owned.map((o) => o.id)); + const rows: any[] = []; + for (let i = 0; i < monitorsList.length; i++) { + const m = monitorsList[i]!; + if (!ownedSet.has(m.monitor_id)) continue; + const groupId = m.group_index != null && groupIds[m.group_index] ? groupIds[m.group_index] : null; + rows.push({ + status_page_id: pageId, + monitor_id: m.monitor_id, + group_id: groupId, + display_name: m.display_name ?? null, + position: m.position ?? i, + }); + } + if (rows.length > 0) { + await sql` + INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "position")} + `; + } + } +} + +export const statusPages = new Elysia({ prefix: "/status-pages" }) + .use(requireAuth) + + .get("/", async ({ accountId }) => { + return sql` + SELECT id, slug, title, description, theme, default_window, created_at, updated_at + FROM status_pages + WHERE account_id = ${accountId} + ORDER BY created_at DESC + `; + }, { detail: { summary: "List status pages", tags: ["status-pages"] } }) + + .post("/", async ({ accountId, body, set }) => { + const password_hash = body.password ? await hashPassword(body.password) : null; + const css = sanitizeCss(body.custom_css); + let row; + try { + [row] = await sql` + INSERT INTO status_pages ( + account_id, slug, title, description, theme, password_hash, index_search, + show_powered_by, show_response_time, show_cert_expiry, default_window, + custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s + ) + VALUES ( + ${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null}, + ${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true}, + ${body.show_powered_by ?? true}, ${body.show_response_time ?? true}, + ${body.show_cert_expiry ?? false}, ${body.default_window ?? '24h'}, + ${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null}, + ${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60} + ) + RETURNING * + `; + } catch (e: any) { + if (e?.code === "23505") { set.status = 409; return { error: "Slug already in use" }; } + throw e; + } + await replaceGroupsAndMonitors(row.id, accountId, body.groups, body.monitors); + return row; + }, { body: StatusPageBody, detail: { summary: "Create status page", tags: ["status-pages"] } }) + + .get("/:id", async ({ accountId, params, set }) => { + const [page] = await sql` + SELECT * FROM status_pages WHERE id = ${params.id} AND account_id = ${accountId} + `; + if (!page) { set.status = 404; return { error: "Not found" }; } + const groups = await sql` + SELECT id, name, position FROM status_page_groups + WHERE status_page_id = ${page.id} ORDER BY position ASC + `; + const monitors = await sql` + SELECT spm.monitor_id, spm.group_id, spm.display_name, spm.position, m.name, m.url + FROM status_page_monitors spm + JOIN monitors m ON m.id = spm.monitor_id + WHERE spm.status_page_id = ${page.id} + ORDER BY spm.position ASC + `; + delete (page as any).password_hash; + return { ...page, has_password: !!(page as any).password_hash || false, groups, monitors }; + }, { detail: { summary: "Get status page", tags: ["status-pages"] } }) + + .patch("/:id", async ({ accountId, params, body, set }) => { + const password_hash = + body.password === undefined ? null + : body.password === null ? null + : await hashPassword(body.password); + const css = body.custom_css === undefined ? null : sanitizeCss(body.custom_css); + let row; + try { + [row] = await sql` + UPDATE status_pages SET + slug = COALESCE(${body.slug ?? null}, slug), + title = COALESCE(${body.title ?? null}, title), + description = COALESCE(${body.description ?? null}, description), + theme = COALESCE(${body.theme ?? null}, theme), + password_hash = CASE WHEN ${body.password === null} THEN NULL + WHEN ${body.password !== undefined} THEN ${password_hash} + ELSE password_hash END, + index_search = COALESCE(${body.index_search ?? null}, index_search), + show_powered_by = COALESCE(${body.show_powered_by ?? null}, show_powered_by), + show_response_time = COALESCE(${body.show_response_time ?? null}, show_response_time), + show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry), + default_window = COALESCE(${body.default_window ?? null}, default_window), + custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END, + footer_text = COALESCE(${body.footer_text ?? null}, footer_text), + og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url), + analytics_html = COALESCE(${body.analytics_html ?? null}, analytics_html), + auto_refresh_s = COALESCE(${body.auto_refresh_s ?? null}, auto_refresh_s), + updated_at = now() + WHERE id = ${params.id} AND account_id = ${accountId} + RETURNING * + `; + } catch (e: any) { + if (e?.code === "23505") { set.status = 409; return { error: "Slug already in use" }; } + throw e; + } + if (!row) { set.status = 404; return { error: "Not found" }; } + await replaceGroupsAndMonitors(row.id, accountId, body.groups, body.monitors); + return row; + }, { body: t.Partial(StatusPageBody), detail: { summary: "Update status page", tags: ["status-pages"] } }) + + .delete("/:id", async ({ accountId, params, set }) => { + const [row] = await sql` + DELETE FROM status_pages WHERE id = ${params.id} AND account_id = ${accountId} + RETURNING id + `; + if (!row) { set.status = 404; return { error: "Not found" }; } + return { deleted: true }; + }, { detail: { summary: "Delete status page", tags: ["status-pages"] } }); diff --git a/apps/shared/db.ts b/apps/shared/db.ts index cdfbee4..09f5840 100644 --- a/apps/shared/db.ts +++ b/apps/shared/db.ts @@ -115,6 +115,125 @@ export async function migrate(sql: any) { `; await sql`CREATE INDEX IF NOT EXISTS idx_monitor_notifications_channel ON monitor_notifications(channel_id)`; + // Tier 3: monitor tags. One row per (monitor, tag). Used by the dashboard + // home filter and the status page builder's "all monitors with tag X" picker. + await sql` + CREATE TABLE IF NOT EXISTS monitor_tags ( + monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (monitor_id, tag) + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_monitor_tags_tag ON monitor_tags(tag)`; + + // Tier 3: public status pages. The whole subgraph below is read by the + // standalone apps/status service; writes happen via apps/api admin routes. + await sql` + CREATE TABLE IF NOT EXISTS status_pages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT, + theme TEXT NOT NULL DEFAULT 'auto', + password_hash TEXT, + index_search BOOLEAN NOT NULL DEFAULT true, + show_powered_by BOOLEAN NOT NULL DEFAULT true, + show_response_time BOOLEAN NOT NULL DEFAULT true, + show_cert_expiry BOOLEAN NOT NULL DEFAULT false, + default_window TEXT NOT NULL DEFAULT '24h', + custom_css TEXT, + footer_text TEXT, + og_image_url TEXT, + analytics_html TEXT, + auto_refresh_s INTEGER NOT NULL DEFAULT 60, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`; + + await sql` + CREATE TABLE IF NOT EXISTS status_page_groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0 + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_status_page_groups_page ON status_page_groups(status_page_id)`; + + await sql` + CREATE TABLE IF NOT EXISTS status_page_monitors ( + status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, + monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + group_id UUID REFERENCES status_page_groups(id) ON DELETE SET NULL, + display_name TEXT, + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (status_page_id, monitor_id) + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_status_page_monitors_monitor ON status_page_monitors(monitor_id)`; + + await sql` + CREATE TABLE IF NOT EXISTS incidents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + title TEXT NOT NULL, + status TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'minor', + pinned BOOLEAN NOT NULL DEFAULT true, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_incidents_account ON incidents(account_id, started_at DESC)`; + + await sql` + CREATE TABLE IF NOT EXISTS incident_updates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, + status TEXT NOT NULL, + body TEXT NOT NULL, + body_html TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON incident_updates(incident_id, created_at)`; + + await sql` + CREATE TABLE IF NOT EXISTS incident_monitors ( + incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, + monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + PRIMARY KEY (incident_id, monitor_id) + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS incident_status_pages ( + incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, + status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, + PRIMARY KEY (incident_id, status_page_id) + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_incident_status_pages_page ON incident_status_pages(status_page_id)`; + + // Shared uptime rollup. One row per (monitor, region, bucket_type, bucket_start). + // Powers status page uptime windows AND any future dashboard widgets. + await sql` + CREATE TABLE IF NOT EXISTS monitor_uptime_rollup ( + monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + region TEXT NOT NULL, + bucket_type TEXT NOT NULL, + bucket_start TIMESTAMPTZ NOT NULL, + total INTEGER NOT NULL, + up_count INTEGER NOT NULL, + avg_latency REAL, + PRIMARY KEY (monitor_id, region, bucket_type, bucket_start) + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_uptime_rollup_lookup ON monitor_uptime_rollup(monitor_id, bucket_type, bucket_start DESC)`; + await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`; diff --git a/apps/shared/render/sparkline.ts b/apps/shared/render/sparkline.ts new file mode 100644 index 0000000..c392b6a --- /dev/null +++ b/apps/shared/render/sparkline.ts @@ -0,0 +1,52 @@ +// Shared sparkline utilities used by both apps/web (dashboard) and apps/status +// (public status pages). Pure HTML/SVG output, no client JS required for the +// first paint. + +import { REGION_COLORS } from "../plans"; + +export function sparkline(values: number[], width = 120, height = 32, color = '#60a5fa', region = 'default'): string { + if (!values.length) return ''; + const max = Math.max(...values, 1); + const min = Math.min(...values, 0); + const range = max - min || 1; + const step = width / Math.max(values.length - 1, 1); + const points = values.map((v, i) => { + const x = i * step; + const y = height - ((v - min) / range) * (height - 4) - 2; + return `${x},${y}`; + }).join(' '); + return ``; +} + +export function pickBestRegion(pings: Array<{ latency_ms?: number | null; region?: string | null }>): { region: string; values: number[]; latest: number | null } { + const withLatency = pings.filter((p) => p.latency_ms != null); + if (!withLatency.length) return { region: 'default', values: [], latest: null }; + + const byRegion: Record = {}; + for (const p of withLatency) { + const key = p.region || 'default'; + if (!byRegion[key]) byRegion[key] = []; + byRegion[key].push(p.latency_ms!); + } + + const recentRegions = new Set(withLatency.slice(-3).map((p) => p.region || 'default')); + + let bestRegion = 'default'; + let bestAvg = Infinity; + for (const [region, vals] of Object.entries(byRegion)) { + if (!recentRegions.has(region)) continue; + const recent = vals.slice(-3); + const avg = recent.reduce((a, b) => a + b, 0) / recent.length; + if (avg < bestAvg) { bestAvg = avg; bestRegion = region; } + } + + const values = byRegion[bestRegion] || []; + return { region: bestRegion, values, latest: values.length ? values[values.length - 1]! : null }; +} + +export function sparklineFromPings(pings: Array<{ latency_ms?: number | null; region?: string | null }>, width = 120, height = 32): string { + const { region, values } = pickBestRegion(pings); + if (!values.length) return ''; + const color = REGION_COLORS[region] || '#60a5fa'; + return sparkline(values, width, height, color, region); +} diff --git a/apps/shared/render/time.ts b/apps/shared/render/time.ts new file mode 100644 index 0000000..90df89e --- /dev/null +++ b/apps/shared/render/time.ts @@ -0,0 +1,13 @@ +// Server-rendered "X ago" timestamp. Returns an HTML span carrying the original +// epoch ms in a data attribute so a tiny client script can refresh it without a +// re-render. Reused by the dashboard and the public status pages. +export function timeAgoSSR(date: string | Date): string { + const ts = new Date(date).getTime(); + const s = Math.ceil((Date.now() - ts) / 1000) || 1; + const text = + s < 60 ? `${s}s ago` + : s < 3600 ? `${Math.floor(s / 60)}m ago` + : s < 86400 ? `${Math.floor(s / 3600)}h ago` + : `${Math.floor(s / 86400)}d ago`; + return `${text}`; +} diff --git a/apps/status/package.json b/apps/status/package.json new file mode 100644 index 0000000..be52e02 --- /dev/null +++ b/apps/status/package.json @@ -0,0 +1,17 @@ +{ + "name": "@pingql/status", + "version": "0.1.0", + "scripts": { + "dev": "bun run --hot src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "elysia": "^1.4.27", + "eta": "^4.5.1", + "postgres": "^3.4.8" + }, + "devDependencies": { + "@types/bun": "^1.3.10", + "typescript": "^5.9.3" + } +} diff --git a/apps/status/src/auth.ts b/apps/status/src/auth.ts new file mode 100644 index 0000000..844bb00 --- /dev/null +++ b/apps/status/src/auth.ts @@ -0,0 +1,47 @@ +// Password gate for protected status pages. We sign a short-lived cookie with +// the page id + a secret so a successful password unlock survives across page +// loads without us having to hit Postgres on every request. + +import { createHmac, timingSafeEqual } from "crypto"; + +const SECRET = process.env.STATUS_COOKIE_SECRET ?? process.env.MONITOR_TOKEN ?? "dev-secret-change-me"; +const COOKIE = "pingql_status_auth"; +const TTL_MS = 12 * 60 * 60 * 1000; // 12 hours + +function sign(payload: string): string { + return createHmac("sha256", SECRET).update(payload).digest("hex"); +} + +export function makeAuthCookie(pageId: string): string { + const exp = Date.now() + TTL_MS; + const payload = `${pageId}.${exp}`; + const sig = sign(payload); + const value = `${payload}.${sig}`; + return `${COOKIE}=${value}; Path=/; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax${process.env.NODE_ENV !== "development" ? "; Secure" : ""}`; +} + +export function verifyAuthCookie(cookieHeader: string | null | undefined, pageId: string): boolean { + if (!cookieHeader) return false; + const match = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`)); + if (!match) return false; + const value = match.slice(COOKIE.length + 1); + const lastDot = value.lastIndexOf("."); + if (lastDot < 0) return false; + const payload = value.slice(0, lastDot); + const sig = value.slice(lastDot + 1); + const [id, expStr] = payload.split("."); + if (id !== pageId) return false; + const exp = Number(expStr); + if (!Number.isFinite(exp) || Date.now() > exp) return false; + const expected = sign(payload); + if (expected.length !== sig.length) return false; + try { + return timingSafeEqual(Buffer.from(expected), Buffer.from(sig)); + } catch { + return false; + } +} + +export async function checkPassword(plain: string, hash: string): Promise { + return await Bun.password.verify(plain, hash); +} diff --git a/apps/status/src/cache.ts b/apps/status/src/cache.ts new file mode 100644 index 0000000..8103f05 --- /dev/null +++ b/apps/status/src/cache.ts @@ -0,0 +1,38 @@ +// Tiny in-memory TTL cache keyed by string. Status pages serve the same payload +// to many visitors during an outage; we don't want every page hit to fan out to +// Postgres. The cache is per-process; behind a load balancer each replica fills +// independently, which is fine — short TTLs converge quickly. + +interface Entry { value: T; expires: number } + +const store = new Map>(); + +export function cacheGet(key: string): T | null { + const entry = store.get(key) as Entry | undefined; + if (!entry) return null; + if (Date.now() > entry.expires) { + store.delete(key); + return null; + } + return entry.value; +} + +export function cacheSet(key: string, value: T, ttlSeconds: number): void { + // Soft cap so a runaway path can't blow memory. LRU-ish: oldest entries get + // dropped first by insertion order (Map preserves it). + if (store.size > 5000) { + const firstKey = store.keys().next().value; + if (firstKey) store.delete(firstKey); + } + store.set(key, { value, expires: Date.now() + ttlSeconds * 1000 }); +} + +// Convenience wrapper: get-or-fill. The producer runs at most once per key +// during the TTL window across this process. +export async function cached(key: string, ttlSeconds: number, producer: () => Promise): Promise { + const hit = cacheGet(key); + if (hit !== null) return hit; + const value = await producer(); + cacheSet(key, value, ttlSeconds); + return value; +} diff --git a/apps/status/src/data.ts b/apps/status/src/data.ts new file mode 100644 index 0000000..f8a27f0 --- /dev/null +++ b/apps/status/src/data.ts @@ -0,0 +1,260 @@ +// Loads the read-only data needed to render a public status page. NEVER reads +// the raw `pings` table — uses `monitor_region_state` for current state and +// `monitor_uptime_rollup` for historical uptime windows. + +import sql from "./db"; + +export type Window = "24h" | "7d" | "30d" | "90d"; +export type BucketType = "hourly" | "daily" | "weekly"; + +const WINDOW_TO_BUCKET: Record = { + "24h": { bucket: "hourly", count: 24 }, + "7d": { bucket: "daily", count: 7 }, + "30d": { bucket: "daily", count: 30 }, + "90d": { bucket: "weekly", count: 13 }, +}; + +export interface StatusPageRow { + id: string; + account_id: string; + slug: string; + title: string; + description: string | null; + theme: "auto" | "light" | "dark"; + password_hash: string | null; + index_search: boolean; + show_powered_by: boolean; + show_response_time:boolean; + show_cert_expiry: boolean; + default_window: Window; + custom_css: string | null; + footer_text: string | null; + og_image_url: string | null; + analytics_html: string | null; + auto_refresh_s: number; +} + +export interface MonitorRow { + id: string; + display_name: string; + url: string; + group_id: string | null; + position: number; + current_state: "up" | "down" | "unknown"; + region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>; + uptime_pct: number | null; // for the page's default_window + buckets: Array<{ start: string; total: number; up: number }>; // bar chart input + avg_latency: number | null; + latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>; +} + +export interface GroupRow { + id: string; + name: string; + position: number; +} + +export interface IncidentSummary { + id: string; + title: string; + status: string; + severity: string; + pinned: boolean; + started_at: string; + resolved_at: string | null; + latest_update_html: string | null; +} + +export async function loadStatusPage(slug: string): Promise { + const [row] = await sql`SELECT * FROM status_pages WHERE slug = ${slug}`; + return row ?? null; +} + +export async function loadGroups(pageId: string): Promise { + return sql` + SELECT id, name, position FROM status_page_groups + WHERE status_page_id = ${pageId} + ORDER BY position ASC, name ASC + `; +} + +export async function loadMonitors(pageId: string, window: Window): Promise { + // Step 1: page → monitors with display overrides + group + position. + const monitorRows = await sql` + SELECT + spm.monitor_id AS id, + COALESCE(spm.display_name, m.name) AS display_name, + m.url, + spm.group_id, + spm.position + FROM status_page_monitors spm + JOIN monitors m ON m.id = spm.monitor_id + WHERE spm.status_page_id = ${pageId} + ORDER BY spm.position ASC, m.name ASC + `; + if (monitorRows.length === 0) return []; + + const ids = monitorRows.map((r) => r.id); + + // Step 2: per-region current state for these monitors. + const stateRows = await sql<{ monitor_id: string; region: string; last_state: string | null; updated_at: string }[]>` + SELECT monitor_id, region, last_state, updated_at + FROM monitor_region_state + WHERE monitor_id = ANY(${sql.array(ids)}::text[]) + `; + const stateByMonitor: Record = {}; + for (const s of stateRows) { + if (!stateByMonitor[s.monitor_id]) stateByMonitor[s.monitor_id] = []; + stateByMonitor[s.monitor_id]!.push({ + region: s.region, + state: (s.last_state as any) ?? "unknown", + updated_at: s.updated_at, + }); + } + + // Step 3: uptime rollup buckets covering the requested window. + const { bucket, count } = WINDOW_TO_BUCKET[window]; + const truncUnit = bucket === "hourly" ? "hour" : bucket === "daily" ? "day" : "week"; + const intervalLiteral = `${count} ${truncUnit}s`; + const rollupRows = await sql` + SELECT monitor_id, bucket_start, sum(total)::int AS total, sum(up_count)::int AS up_count, avg(avg_latency)::real AS avg_latency + FROM monitor_uptime_rollup + WHERE monitor_id = ANY(${sql.array(ids)}::text[]) + AND bucket_type = ${bucket} + AND bucket_start > date_trunc(${truncUnit}, now()) - ${intervalLiteral}::interval + GROUP BY monitor_id, bucket_start + ORDER BY monitor_id, bucket_start ASC + `; + const bucketsByMonitor: Record = {}; + const latencyByMonitor: Record = {}; + for (const r of rollupRows) { + if (!bucketsByMonitor[r.monitor_id]) bucketsByMonitor[r.monitor_id] = []; + bucketsByMonitor[r.monitor_id]!.push({ + start: r.bucket_start instanceof Date ? r.bucket_start.toISOString() : String(r.bucket_start), + total: r.total, + up: r.up_count, + }); + if (r.avg_latency != null) { + const acc = latencyByMonitor[r.monitor_id] ?? { sum: 0, n: 0 }; + acc.sum += r.avg_latency * r.total; + acc.n += r.total; + latencyByMonitor[r.monitor_id] = acc; + } + } + + // Step 4: tiny recent latency history for the sparkline (last 30 hourly buckets). + const latRows = await sql` + SELECT monitor_id, region, bucket_start, avg_latency + FROM monitor_uptime_rollup + WHERE monitor_id = ANY(${sql.array(ids)}::text[]) + AND bucket_type = 'hourly' + AND bucket_start > now() - interval '30 hours' + ORDER BY monitor_id, bucket_start ASC + `; + const latencyByMonitorList: Record = {}; + for (const r of latRows) { + if (!latencyByMonitorList[r.monitor_id]) latencyByMonitorList[r.monitor_id] = []; + latencyByMonitorList[r.monitor_id]!.push({ + region: r.region, + latency_ms: r.avg_latency != null ? Math.round(r.avg_latency) : null, + ts: r.bucket_start instanceof Date ? r.bucket_start.toISOString() : String(r.bucket_start), + }); + } + + return monitorRows.map((m) => { + const region_states = stateByMonitor[m.id] ?? []; + let current_state: MonitorRow["current_state"] = "unknown"; + if (region_states.length > 0) { + const anyDown = region_states.some((s) => s.state === "down"); + const anyUp = region_states.some((s) => s.state === "up"); + current_state = anyDown ? "down" : anyUp ? "up" : "unknown"; + } + const buckets = bucketsByMonitor[m.id] ?? []; + let uptime_pct: number | null = null; + if (buckets.length > 0) { + const tot = buckets.reduce((a, b) => a + b.total, 0); + const upT = buckets.reduce((a, b) => a + b.up, 0); + uptime_pct = tot > 0 ? +(100 * upT / tot).toFixed(2) : null; + } + const latAcc = latencyByMonitor[m.id]; + const avg_latency = latAcc && latAcc.n > 0 ? Math.round(latAcc.sum / latAcc.n) : null; + return { + id: m.id, + display_name: m.display_name, + url: m.url, + group_id: m.group_id, + position: m.position, + current_state, + region_states, + uptime_pct, + buckets, + avg_latency, + latency_history: latencyByMonitorList[m.id] ?? [], + } as MonitorRow; + }); +} + +export async function loadIncidents(pageId: string): Promise<{ active: IncidentSummary[]; recent: IncidentSummary[] }> { + const incidents = await sql` + SELECT i.* + FROM incidents i + JOIN incident_status_pages isp ON isp.incident_id = i.id + WHERE isp.status_page_id = ${pageId} + ORDER BY i.started_at DESC + LIMIT 50 + `; + if (incidents.length === 0) return { active: [], recent: [] }; + + const ids = incidents.map((i) => i.id); + // Latest update html per incident. + const latestUpdates = await sql` + SELECT DISTINCT ON (incident_id) incident_id, body_html, status, created_at + FROM incident_updates + WHERE incident_id = ANY(${sql.array(ids)}::uuid[]) + ORDER BY incident_id, created_at DESC + `; + const latestByIncident: Record = {}; + for (const u of latestUpdates) latestByIncident[u.incident_id] = u.body_html; + + const enriched: IncidentSummary[] = incidents.map((i) => ({ + id: i.id, + title: i.title, + status: i.status, + severity: i.severity, + pinned: i.pinned, + started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at), + resolved_at: i.resolved_at ? (i.resolved_at instanceof Date ? i.resolved_at.toISOString() : String(i.resolved_at)) : null, + latest_update_html: latestByIncident[i.id] ?? null, + })); + + const active = enriched.filter((i) => i.pinned && !i.resolved_at); + const recent = enriched.filter((i) => !active.includes(i)); + return { active, recent }; +} + +export interface PagePayload { + page: Omit & { has_password: boolean }; + groups: GroupRow[]; + monitors: MonitorRow[]; + incidents: { active: IncidentSummary[]; recent: IncidentSummary[] }; + generated_at: string; +} + +export async function loadPagePayload(slug: string, window?: Window): Promise { + const page = await loadStatusPage(slug); + if (!page) return null; + const win = (window ?? page.default_window) as Window; + const [groups, monitors, incidents] = await Promise.all([ + loadGroups(page.id), + loadMonitors(page.id, win), + loadIncidents(page.id), + ]); + const { password_hash, ...publicPage } = page; + return { + page: { ...publicPage, has_password: !!password_hash }, + groups, + monitors, + incidents, + generated_at: new Date().toISOString(), + }; +} diff --git a/apps/status/src/db.ts b/apps/status/src/db.ts new file mode 100644 index 0000000..c73d70d --- /dev/null +++ b/apps/status/src/db.ts @@ -0,0 +1,11 @@ +// Read-only Postgres client. The status service does NOT run migrations — +// schema is owned by apps/api. This file just opens a connection. +import postgres from "postgres"; + +const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", { + max: 10, + idle_timeout: 30, + connect_timeout: 10, +}); + +export default sql; diff --git a/apps/status/src/index.ts b/apps/status/src/index.ts new file mode 100644 index 0000000..19a52ec --- /dev/null +++ b/apps/status/src/index.ts @@ -0,0 +1,172 @@ +import { Elysia } from "elysia"; +import { Eta } from "eta"; +import { resolve } from "path"; +import sql from "./db"; +import { loadStatusPage, loadPagePayload, type Window } from "./data"; +import { renderRss } from "./render/rss"; +import { renderBadge, badgeFromState } from "./render/badge"; +import { cached } from "./cache"; +import { allow } from "./rate-limit"; +import { checkPassword, makeAuthCookie, verifyAuthCookie } from "./auth"; + +// Crash isolation: log loudly, never exit. Status pages going down silently is +// worse than weird logs. +process.on("unhandledRejection", (reason) => console.error("[unhandledRejection]", reason)); +process.on("uncaughtException", (err) => console.error("[uncaughtException]", err)); + +const eta = new Eta({ views: resolve(import.meta.dir, "./views"), cache: true, defaultExtension: ".ejs" }); + +const PUBLIC_BASE = process.env.STATUS_BASE_URL ?? "https://status.pingql.com"; + +function clientIp(req: Request): string { + return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || req.headers.get("cf-connecting-ip") + || "unknown"; +} + +function notFound(): Response { + return new Response(eta.render("not-found", {}), { + status: 404, + headers: { "content-type": "text/html; charset=utf-8" }, + }); +} + +function rateLimited(): Response { + return new Response("Too many requests", { status: 429 }); +} + +function isAuthorised(page: { id: string; password_hash: string | null }, req: Request): boolean { + if (!page.password_hash) return true; + return verifyAuthCookie(req.headers.get("cookie"), page.id); +} + +const app = new Elysia() + .get("/", () => new Response("PingQL status service", { + headers: { "content-type": "text/plain" }, + })) + + // Public HTML page + .get("/:slug", async ({ params, request, set }) => { + if (!allow(params.slug, clientIp(request))) return rateLimited(); + const page = await cached(`page:${params.slug}`, 60, () => loadStatusPage(params.slug)); + if (!page) return notFound(); + if (!isAuthorised(page, request)) { + return new Response(eta.render("password", { title: page.title, slug: page.slug, error: null }), { + status: 401, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + const payload = await cached(`payload:${params.slug}`, 60, () => loadPagePayload(params.slug)); + if (!payload) return notFound(); + + const html = eta.render("page", payload); + const headers: Record = { + "content-type": "text/html; charset=utf-8", + "cache-control": "public, max-age=30, s-maxage=60", + "x-frame-options":"SAMEORIGIN", + "x-content-type-options": "nosniff", + "referrer-policy":"strict-origin-when-cross-origin", + }; + if (!page.index_search) headers["x-robots-tag"] = "noindex, nofollow"; + return new Response(html, { headers }); + }) + + // Public JSON + .get("/:slug.json", async ({ params, request, set, query }) => { + if (!allow(params.slug, clientIp(request))) return rateLimited(); + const page = await cached(`page:${params.slug}`, 60, () => loadStatusPage(params.slug)); + if (!page) { set.status = 404; return { error: "not found" }; } + if (!isAuthorised(page, request)) { set.status = 401; return { error: "password required" }; } + + const win = (query as any)?.window as Window | undefined; + const cacheKey = `payload:${params.slug}:${win ?? page.default_window}`; + const payload = await cached(cacheKey, 60, () => loadPagePayload(params.slug, win)); + if (!payload) { set.status = 404; return { error: "not found" }; } + return new Response(JSON.stringify(payload), { + headers: { + "content-type": "application/json", + "cache-control": "public, max-age=30, s-maxage=60", + ...(page.index_search ? {} : { "x-robots-tag": "noindex, nofollow" }), + }, + }); + }) + + // Public RSS + .get("/:slug.rss", async ({ params, request }) => { + if (!allow(params.slug, clientIp(request))) return rateLimited(); + const page = await loadStatusPage(params.slug); + if (!page) return notFound(); + const xml = await cached(`rss:${params.slug}`, 300, () => renderRss(page, PUBLIC_BASE)); + return new Response(xml, { + headers: { + "content-type": "application/rss+xml; charset=utf-8", + "cache-control": "public, max-age=300, s-maxage=300", + }, + }); + }) + + // Public SVG badge + .get("/:slug/badge.svg", async ({ params, request }) => { + if (!allow(params.slug, clientIp(request))) return rateLimited(); + const payload = await cached(`payload:${params.slug}`, 60, () => loadPagePayload(params.slug)); + if (!payload) return notFound(); + const { message, color } = badgeFromState(payload.monitors); + const svg = renderBadge("status", message, color); + return new Response(svg, { + headers: { + "content-type": "image/svg+xml", + "cache-control": "public, max-age=60, s-maxage=60", + }, + }); + }) + + // PWA manifest + .get("/:slug/manifest.json", async ({ params }) => { + const page = await loadStatusPage(params.slug); + if (!page) return notFound(); + return new Response(JSON.stringify({ + name: page.title, + short_name: page.title.slice(0, 12), + description: page.description ?? "", + start_url: `/${page.slug}`, + display: "standalone", + background_color: page.theme === "light" ? "#ffffff" : "#0a0a0a", + theme_color: page.theme === "light" ? "#0ea5e9" : "#0a0a0a", + }), { + headers: { + "content-type": "application/manifest+json", + "cache-control": "public, max-age=86400, s-maxage=86400", + }, + }); + }) + + // Password gate POST + .post("/:slug/auth", async ({ params, request }) => { + if (!allow(params.slug, clientIp(request))) return rateLimited(); + const page = await loadStatusPage(params.slug); + if (!page) return notFound(); + if (!page.password_hash) { + return Response.redirect(`/${page.slug}`, 303); + } + const form = await request.formData(); + const password = String(form.get("password") ?? ""); + const ok = await checkPassword(password, page.password_hash); + if (!ok) { + return new Response(eta.render("password", { title: page.title, slug: page.slug, error: "Wrong password" }), { + status: 401, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + return new Response(null, { + status: 303, + headers: { "location": `/${page.slug}`, "set-cookie": makeAuthCookie(page.id) }, + }); + }); + +const port = Number(process.env.STATUS_PORT ?? 3003); +const server = Bun.serve({ + port, + fetch(req) { return app.handle(req); }, +}); + +console.log(`PingQL status service running at http://localhost:${server.port}`); diff --git a/apps/status/src/rate-limit.ts b/apps/status/src/rate-limit.ts new file mode 100644 index 0000000..6b21534 --- /dev/null +++ b/apps/status/src/rate-limit.ts @@ -0,0 +1,31 @@ +// Per-(slug, IP) token bucket. 30 requests in a 10s window. Cheap, in-memory, +// resets on process restart. Behind a load balancer each replica enforces its +// own bucket — that's fine, the goal is "stop a hostile script from melting one +// box", not perfect distributed accounting. + +interface Bucket { tokens: number; refillAt: number } + +const buckets = new Map(); +const CAPACITY = 30; +const WINDOW_MS = 10_000; + +export function allow(slug: string, ip: string): boolean { + const key = `${slug}\x00${ip}`; + const now = Date.now(); + let b = buckets.get(key); + if (!b || now > b.refillAt) { + b = { tokens: CAPACITY, refillAt: now + WINDOW_MS }; + buckets.set(key, b); + } + if (b.tokens <= 0) return false; + b.tokens--; + return true; +} + +// Periodic sweep so the map doesn't grow forever. +setInterval(() => { + const now = Date.now(); + for (const [k, b] of buckets) { + if (now > b.refillAt + WINDOW_MS) buckets.delete(k); + } +}, 60_000).unref?.(); diff --git a/apps/status/src/render/badge.ts b/apps/status/src/render/badge.ts new file mode 100644 index 0000000..f3e9280 --- /dev/null +++ b/apps/status/src/render/badge.ts @@ -0,0 +1,33 @@ +// Shields-style SVG badge for embedding on README files etc. + +export function renderBadge(label: string, message: string, color: string): string { + // Approximate text width: 6.5px per char + 10px padding each side. + const labelW = 10 + label.length * 6.5 + 10; + const messageW = 10 + message.length * 6.5 + 10; + const total = labelW + messageW; + return ` + + + + + + + + + ${escapeXml(label)} + ${escapeXml(message)} + +`; +} + +export function badgeFromState(monitors: Array<{ current_state: "up" | "down" | "unknown" }>): { message: string; color: string } { + if (monitors.length === 0) return { message: "no data", color: "#9f9f9f" }; + const down = monitors.filter((m) => m.current_state === "down").length; + if (down === 0) return { message: "operational", color: "#4c1" }; + if (down < monitors.length) return { message: "degraded", color: "#dfb317" }; + return { message: "down", color: "#e05d44" }; +} + +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} diff --git a/apps/status/src/render/rss.ts b/apps/status/src/render/rss.ts new file mode 100644 index 0000000..5cd7a56 --- /dev/null +++ b/apps/status/src/render/rss.ts @@ -0,0 +1,62 @@ +// RSS 2.0 feed of incidents on a status page. Unlike Uptime Kuma we include all +// incident lifecycle events (investigating / identified / monitoring / resolved), +// not just initial outages, so subscribers see the full timeline. + +import sql from "../db"; +import type { StatusPageRow } from "../data"; + +interface FeedItem { + guid: string; + title: string; + link: string; + pubDate: string; + body_html: string; +} + +export async function renderRss(page: StatusPageRow, baseUrl: string): Promise { + const updates = await sql` + SELECT iu.id, iu.status, iu.body_html, iu.created_at, i.title AS incident_title, i.id AS incident_id + FROM incident_updates iu + JOIN incidents i ON i.id = iu.incident_id + JOIN incident_status_pages isp ON isp.incident_id = i.id + WHERE isp.status_page_id = ${page.id} + ORDER BY iu.created_at DESC + LIMIT 50 + `; + + const items: FeedItem[] = updates.map((u) => ({ + guid: `update-${u.id}`, + title: `[${u.status}] ${u.incident_title}`, + link: `${baseUrl}/${page.slug}#incident-${u.incident_id}`, + pubDate: new Date(u.created_at).toUTCString(), + body_html: u.body_html, + })); + + const channelTitle = escapeXml(`${page.title} — Incidents`); + const channelDescription = escapeXml(page.description || `${page.title} status updates`); + const channelLink = `${baseUrl}/${page.slug}`; + + const itemsXml = items.map((it) => ` + + ${it.guid} + ${escapeXml(it.title)} + ${escapeXml(it.link)} + ${it.pubDate} + + `).join(""); + + return ` + + + ${channelTitle} + ${escapeXml(channelLink)} + ${channelDescription} + 300 + ${itemsXml} + +`; +} + +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} diff --git a/apps/status/src/views/not-found.ejs b/apps/status/src/views/not-found.ejs new file mode 100644 index 0000000..7634f2f --- /dev/null +++ b/apps/status/src/views/not-found.ejs @@ -0,0 +1,4 @@ + +Not found + +

Status page not found

The page you're looking for doesn't exist.

PingQL

diff --git a/apps/status/src/views/page.ejs b/apps/status/src/views/page.ejs new file mode 100644 index 0000000..52c128d --- /dev/null +++ b/apps/status/src/views/page.ejs @@ -0,0 +1,223 @@ +<% + const page = it.page; + const monitors = it.monitors; + const groups = it.groups; + const incidents = it.incidents; + const themeClass = page.theme === 'dark' ? 'dark' : page.theme === 'light' ? 'light' : ''; + + // Group monitors. group_id null = "ungrouped". + const grouped = {}; + for (const m of monitors) { + const key = m.group_id || ''; + if (!grouped[key]) grouped[key] = []; + grouped[key].push(m); + } + const groupOrder = [...groups.map(g => g.id), '']; + + function fmtPct(p) { + if (p == null) return '—'; + return p === 100 ? '100%' : p.toFixed(2) + '%'; + } + function statusLabel(s) { + if (s === 'up') return 'Operational'; + if (s === 'down') return 'Down'; + return 'Unknown'; + } + function statusColor(s) { + if (s === 'up') return '#10b981'; + if (s === 'down') return '#ef4444'; + return '#9ca3af'; + } + function bucketColor(b) { + if (b.total === 0) return '#374151'; + if (b.up === b.total) return '#10b981'; + if (b.up === 0) return '#ef4444'; + return '#f59e0b'; + } + + // Overall status: down if any monitor is down, degraded if any partial, else up. + let overall = 'up'; + for (const m of monitors) { + const partial = m.region_states.some(r => r.state === 'down') && m.region_states.some(r => r.state === 'up'); + if (m.current_state === 'down') { overall = 'down'; break; } + if (partial && overall !== 'down') overall = 'degraded'; + } + const overallText = overall === 'up' ? 'All systems operational' + : overall === 'degraded' ? 'Some systems degraded' + : 'Major outage in progress'; + const overallColor = overall === 'up' ? '#10b981' + : overall === 'degraded' ? '#f59e0b' + : '#ef4444'; +%> + + + + + <%= page.title %> + <% if (page.description) { %><% } %> + <% if (!page.index_search) { %><% } %> + + <% if (page.description) { %><% } %> + <% if (page.og_image_url) { %><% } %> + + + + <% if (page.analytics_html) { %><%~ page.analytics_html %><% } %> + + +
+

<%= page.title %>

+ <% if (page.description) { %>
<%= page.description %>
<% } %> + +
+ + <%= overallText %> +
+ + <% if (incidents.active.length > 0) { %> +
+ <% incidents.active.forEach(function(i) { %> +
+
<%= i.title %>
+
<%= i.status %> · started <%= new Date(i.started_at).toLocaleString() %>
+ <% if (i.latest_update_html) { %>
<%~ i.latest_update_html %>
<% } %> +
+ <% }); %> +
+ <% } %> + + <% groupOrder.forEach(function(gid) { + const list = grouped[gid]; + if (!list || list.length === 0) return; + const groupName = gid ? (groups.find(g => g.id === gid)?.name || '') : ''; + %> + <% if (groupName) { %>
<%= groupName %>
<% } %> +
+ <% list.forEach(function(m) { %> +
+
+
+ + <%= m.display_name %> +
+
+ <% if (page.show_response_time && m.avg_latency != null) { %><%= m.avg_latency %>ms<% } %> + <%= fmtPct(m.uptime_pct) %> +
+
+ <% if (m.buckets && m.buckets.length > 0) { %> +
+ <% m.buckets.forEach(function(b) { %> +
+ <% }); %> +
+ <% } %> + <% if (m.region_states && m.region_states.length > 1) { %> +
+ <% m.region_states.forEach(function(r) { %> + <%= r.region %> + <% }); %> +
+ <% } %> +
+ <% }); %> +
+ <% }); %> + + <% if (incidents.recent.length > 0) { %> +
+

Past incidents

+ <% incidents.recent.forEach(function(i) { %> +
+
+
<%= i.title %>
+
<%= i.status %> · <%= new Date(i.started_at).toLocaleDateString() %>
+
+
+ <% }); %> +
+ <% } %> + +
+ <% if (page.footer_text) { %>
<%= page.footer_text %>
<% } %> + <% if (page.show_powered_by) { %>
Status powered by PingQL
<% } %> +
+
+ + <% if (page.auto_refresh_s > 0) { %> + + <% } %> + + diff --git a/apps/status/src/views/password.ejs b/apps/status/src/views/password.ejs new file mode 100644 index 0000000..fd6869a --- /dev/null +++ b/apps/status/src/views/password.ejs @@ -0,0 +1,31 @@ + + + + + + + <%= it.title %> — Password required + + + +
+

<%= it.title %>

+

This status page is password protected.

+
+ + + <% if (it.error) { %>
<%= it.error %>
<% } %> +
+
+ + diff --git a/apps/status/tsconfig.json b/apps/status/tsconfig.json new file mode 100644 index 0000000..ba3a896 --- /dev/null +++ b/apps/status/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ESNext", + "strict": true, + "skipLibCheck": true, + "types": ["bun"], + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"] +} diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index f2e24c0..1251cfb 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -607,6 +607,238 @@ export const dashboard = new Elysia() return redirect(`/dashboard/monitors/${params.id}`); }) + // ── Status pages ────────────────────────────────────────────────── + .get("/dashboard/status-pages", async ({ cookie, headers }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const pages = await sql` + SELECT id, slug, title, description, theme, default_window + FROM status_pages WHERE account_id = ${resolved.accountId} + ORDER BY created_at DESC + `; + return html("status-pages", { nav: "status-pages", pages }); + }) + + .get("/dashboard/status-pages/new", async ({ cookie, headers }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const allMonitors = await sql` + SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC + `; + return html("status-page-edit", { nav: "status-pages", isNew: true, page: null, allMonitors }); + }) + + .get("/dashboard/status-pages/:id", async ({ cookie, headers, params }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const [page] = await sql` + SELECT * FROM status_pages WHERE id = ${params.id} AND account_id = ${resolved.accountId} + `; + if (!page) return redirect("/dashboard/status-pages"); + const monitors = await sql` + SELECT monitor_id FROM status_page_monitors WHERE status_page_id = ${params.id} + `; + const allMonitors = await sql` + SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC + `; + page.monitors = monitors; + return html("status-page-edit", { nav: "status-pages", isNew: false, page, allMonitors }); + }) + + .post("/dashboard/status-pages/new", async ({ cookie, headers, body }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const b = body as any; + const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []); + try { + const apiUrl = process.env.API_URL || "https://api.pingql.com"; + const key = cookie?.pingql_key?.value; + await fetch(`${apiUrl}/status-pages/`, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` }, + body: JSON.stringify({ + slug: (b.slug || "").trim(), + title: b.title, + description: b.description || null, + theme: b.theme || "auto", + default_window: b.default_window || "24h", + show_response_time: !!b.show_response_time, + show_powered_by: !!b.show_powered_by, + index_search: !!b.index_search, + password: b.password || undefined, + custom_css: b.custom_css || null, + footer_text: b.footer_text || null, + monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i })), + }), + }); + } catch {} + return redirect("/dashboard/status-pages"); + }) + + .post("/dashboard/status-pages/:id/edit", async ({ cookie, headers, params, body }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const b = body as any; + const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []); + try { + const apiUrl = process.env.API_URL || "https://api.pingql.com"; + const key = cookie?.pingql_key?.value; + const payload: any = { + slug: (b.slug || "").trim(), + title: b.title, + description: b.description || null, + theme: b.theme || "auto", + default_window: b.default_window || "24h", + show_response_time: !!b.show_response_time, + show_powered_by: !!b.show_powered_by, + index_search: !!b.index_search, + custom_css: b.custom_css || null, + footer_text: b.footer_text || null, + monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i })), + }; + // Only send `password` if the user actually typed something. An empty box + // means "leave the existing password as-is" — sending null would clear it. + if (b.password) payload.password = b.password; + await fetch(`${apiUrl}/status-pages/${params.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` }, + body: JSON.stringify(payload), + }); + } catch {} + return redirect("/dashboard/status-pages"); + }) + + .post("/dashboard/status-pages/:id/delete", async ({ cookie, headers, params }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + try { + const apiUrl = process.env.API_URL || "https://api.pingql.com"; + const key = cookie?.pingql_key?.value; + await fetch(`${apiUrl}/status-pages/${params.id}`, { + method: "DELETE", + headers: { "Authorization": `Bearer ${key}` }, + }); + } catch {} + return redirect("/dashboard/status-pages"); + }) + + // ── Incidents ───────────────────────────────────────────────────── + .get("/dashboard/incidents", async ({ cookie, headers }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const incidents = await sql` + SELECT id, title, status, severity, pinned, started_at, resolved_at + FROM incidents WHERE account_id = ${resolved.accountId} + ORDER BY started_at DESC LIMIT 200 + `; + return html("incidents", { nav: "incidents", incidents }); + }) + + .get("/dashboard/incidents/new", async ({ cookie, headers }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const allMonitors = await sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`; + const allPages = await sql`SELECT id, title FROM status_pages WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`; + return html("incident-edit", { nav: "incidents", isNew: true, incident: null, allMonitors, allPages }); + }) + + .get("/dashboard/incidents/:id", async ({ cookie, headers, params }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const [incident] = await sql` + SELECT * FROM incidents WHERE id = ${params.id} AND account_id = ${resolved.accountId} + `; + if (!incident) return redirect("/dashboard/incidents"); + const updates = await sql`SELECT * FROM incident_updates WHERE incident_id = ${params.id} ORDER BY created_at ASC`; + const monitors = await sql`SELECT monitor_id FROM incident_monitors WHERE incident_id = ${params.id}`; + const pages = await sql`SELECT status_page_id FROM incident_status_pages WHERE incident_id = ${params.id}`; + incident.updates = updates; + incident.monitor_ids = monitors.map((m: any) => m.monitor_id); + incident.status_page_ids = pages.map((p: any) => p.status_page_id); + const allMonitors = await sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`; + const allPages = await sql`SELECT id, title FROM status_pages WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`; + return html("incident-edit", { nav: "incidents", isNew: false, incident, allMonitors, allPages }); + }) + + .post("/dashboard/incidents/new", async ({ cookie, headers, body }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const b = body as any; + const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []); + const pageIds = Array.isArray(b.status_page_ids) ? b.status_page_ids : (b.status_page_ids ? [b.status_page_ids] : []); + try { + const apiUrl = process.env.API_URL || "https://api.pingql.com"; + const key = cookie?.pingql_key?.value; + await fetch(`${apiUrl}/incidents/`, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` }, + body: JSON.stringify({ + title: b.title, + status: b.status || "investigating", + severity: b.severity || "minor", + monitor_ids: monitorIds, + status_page_ids: pageIds, + initial_update: { body: b.initial_update_body || "Investigating." }, + }), + }); + } catch {} + return redirect("/dashboard/incidents"); + }) + + .post("/dashboard/incidents/:id/edit", async ({ cookie, headers, params, body }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const b = body as any; + const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []); + const pageIds = Array.isArray(b.status_page_ids) ? b.status_page_ids : (b.status_page_ids ? [b.status_page_ids] : []); + try { + const apiUrl = process.env.API_URL || "https://api.pingql.com"; + const key = cookie?.pingql_key?.value; + await fetch(`${apiUrl}/incidents/${params.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` }, + body: JSON.stringify({ + title: b.title, + status: b.status, + severity: b.severity, + monitor_ids: monitorIds, + status_page_ids: pageIds, + }), + }); + } catch {} + return redirect(`/dashboard/incidents/${params.id}`); + }) + + .post("/dashboard/incidents/:id/update", async ({ cookie, headers, params, body }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const b = body as any; + try { + const apiUrl = process.env.API_URL || "https://api.pingql.com"; + const key = cookie?.pingql_key?.value; + await fetch(`${apiUrl}/incidents/${params.id}/updates`, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` }, + body: JSON.stringify({ status: b.status, body: b.body }), + }); + } catch {} + return redirect(`/dashboard/incidents/${params.id}`); + }) + + .post("/dashboard/incidents/:id/delete", async ({ cookie, headers, params }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + try { + const apiUrl = process.env.API_URL || "https://api.pingql.com"; + const key = cookie?.pingql_key?.value; + await fetch(`${apiUrl}/incidents/${params.id}`, { + method: "DELETE", + headers: { "Authorization": `Bearer ${key}` }, + }); + } catch {} + return redirect("/dashboard/incidents"); + }) + .get("/docs", () => html("docs", {})) .get("/privacy", () => html("privacy", {})) .get("/terms", () => html("tos", {})); diff --git a/apps/web/src/utils/sparkline.ts b/apps/web/src/utils/sparkline.ts index c34f1f6..2e17e13 100644 --- a/apps/web/src/utils/sparkline.ts +++ b/apps/web/src/utils/sparkline.ts @@ -1,50 +1,3 @@ -export function sparkline(values: number[], width = 120, height = 32, color = '#60a5fa', region = '__none__'): string { - if (!values.length) return ''; - const max = Math.max(...values, 1); - const min = Math.min(...values, 0); - const range = max - min || 1; - const step = width / Math.max(values.length - 1, 1); - const points = values.map((v, i) => { - const x = i * step; - const y = height - ((v - min) / range) * (height - 4) - 2; - return `${x},${y}`; - }).join(' '); - return ``; -} - -import { REGION_COLORS } from "../../../shared/plans"; - -export function pickBestRegion(pings: Array<{latency_ms?: number|null, region?: string|null}>): { region: string, values: number[], latest: number | null } { - const withLatency = pings.filter(p => p.latency_ms != null); - if (!withLatency.length) return { region: '__none__', values: [], latest: null }; - - const byRegion: Record = {}; - for (const p of withLatency) { - const key = p.region || '__none__'; - if (!byRegion[key]) byRegion[key] = []; - byRegion[key].push(p.latency_ms!); - } - - const recentRegions = new Set( - withLatency.slice(-3).map(p => p.region || '__none__') - ); - - let bestRegion = '__none__'; - let bestAvg = Infinity; - for (const [region, vals] of Object.entries(byRegion)) { - if (!recentRegions.has(region)) continue; - const recent = vals.slice(-3); - const avg = recent.reduce((a, b) => a + b, 0) / recent.length; - if (avg < bestAvg) { bestAvg = avg; bestRegion = region; } - } - - const values = byRegion[bestRegion] || []; - return { region: bestRegion, values, latest: values.length ? values[values.length - 1] : null }; -} - -export function sparklineFromPings(pings: Array<{latency_ms?: number|null, region?: string|null}>, width = 120, height = 32): string { - const { region, values } = pickBestRegion(pings); - if (!values.length) return ''; - const color = REGION_COLORS[region] || '#60a5fa'; - return sparkline(values, width, height, color, region); -} +// Re-export from the shared module so apps/web and apps/status share one +// sparkline implementation. +export { sparkline, sparklineFromPings, pickBestRegion } from "../../../shared/render/sparkline"; diff --git a/apps/web/src/views/docs.ejs b/apps/web/src/views/docs.ejs index 240eaf7..9de2590 100644 --- a/apps/web/src/views/docs.ejs +++ b/apps/web/src/views/docs.ejs @@ -482,6 +482,39 @@ Content-Type: application/json

Status page (HTML)

json
{ "$select": { ".status-indicator": { "$eq": "All systems operational" } } }
+ +

Page down if a JSON queue is backed up

+

The killer demo: alert when a JSON field crosses a threshold. Uptime Kuma's keyword/json-query checks can't compose this — you'd need a script. Here it's one expression.

+
json
+
{
+  "$consider": "down",
+  "$json": { "$.queue.depth": { "$gt": 1000 } }
+}
+ +

Down if any signal looks bad

+

Compose multiple conditions with $or and flip the result with $consider. Each condition can mix status, body, headers, JSON, and CSS-selector checks freely.

+
json
+
{
+  "$consider": "down",
+  "$or": [
+    { "status": { "$gte": 500 } },
+    { "$responseTime": { "$gt": 3000 } },
+    { "$json": { "$.healthy": { "$eq": false } } },
+    { "$select": { ".error-banner": { "$exists": true } } }
+  ]
+}
+ +

Up only when everything matches

+

Combine $and with header, body, and JSON checks for a strict definition of healthy.

+
json
+
{
+  "$and": [
+    { "status": 200 },
+    { "headers.content-type": { "$contains": "application/json" } },
+    { "$json": { "$.version": { "$startsWith": "v2" } } },
+    { "$json": { "$.db.connections": { "$lt": 100 } } }
+  ]
+}
diff --git a/apps/web/src/views/incident-edit.ejs b/apps/web/src/views/incident-edit.ejs new file mode 100644 index 0000000..d40eea6 --- /dev/null +++ b/apps/web/src/views/incident-edit.ejs @@ -0,0 +1,121 @@ +<%~ include('./partials/head', { title: it.isNew ? 'New incident' : 'Edit incident' }) %> +<%~ include('./partials/nav', { nav: 'incidents' }) %> + +<% + const i = it.incident || {}; + const allMonitors = it.allMonitors || []; + const allPages = it.allPages || []; + const attachedMonitors = new Set((it.incident?.monitor_ids || [])); + const attachedPages = new Set((it.incident?.status_page_ids || [])); + const updates = it.incident?.updates || []; +%> + +
+ +
+ ← Back to incidents +

<%= it.isNew ? 'New incident' : 'Edit incident' %>

+
+ +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + <% if (allMonitors.length === 0) { %> +

No monitors yet.

+ <% } else { %> +
+ <% allMonitors.forEach(function(m) { %> + + <% }) %> +
+ <% } %> +
+ +
+ + <% if (allPages.length === 0) { %> +

No status pages yet. Create one.

+ <% } else { %> +
+ <% allPages.forEach(function(p) { %> + + <% }) %> +
+ <% } %> +
+ + <% if (it.isNew) { %> +
+ + +

Markdown supported: **bold**, *italic*, `code`, [link](https://...)

+
+ <% } %> + + +
+ + <% if (!it.isNew && updates.length > 0) { %> +
+

Timeline

+
+ <% updates.slice().reverse().forEach(function(u) { %> +
+
<%= u.status %> · <%~ it.timeAgoSSR(u.created_at) %>
+
<%~ u.body_html %>
+
+ <% }) %> +
+
+ <% } %> + + <% if (!it.isNew) { %> +
+

Post update

+
+ + + +
+
+ <% } %> + +
diff --git a/apps/web/src/views/incidents.ejs b/apps/web/src/views/incidents.ejs new file mode 100644 index 0000000..2c58e57 --- /dev/null +++ b/apps/web/src/views/incidents.ejs @@ -0,0 +1,46 @@ +<%~ include('./partials/head', { title: 'Incidents' }) %> +<%~ include('./partials/nav', { nav: 'incidents' }) %> + +
+ +
+

Incidents

+ + New incident +
+ +

+ Manually-posted incidents that show up on attached status pages with a timeline of updates. +

+ + <% if (!it.incidents || it.incidents.length === 0) { %> +
No incidents yet.
+ <% } else { %> + <% it.incidents.forEach(function(i) { + const sevColor = i.severity === 'critical' ? 'text-red-400 border-red-900/30' + : i.severity === 'major' ? 'text-amber-400 border-amber-900/30' + : 'text-gray-400 border-border-subtle'; + const statusColor = i.status === 'resolved' ? 'bg-green-900/20 text-green-400 border-green-800/30' + : 'bg-yellow-900/20 text-yellow-400 border-yellow-800/30'; + %> +
+
+
+
+

<%= i.title %>

+ <%= i.severity %> + <%= i.status %> +
+
Started <%~ it.timeAgoSSR(i.started_at) %><% if (i.resolved_at) { %> · Resolved <%~ it.timeAgoSSR(i.resolved_at) %><% } %>
+
+
+ Open +
+ +
+
+
+
+ <% }) %> + <% } %> + +
diff --git a/apps/web/src/views/partials/nav.ejs b/apps/web/src/views/partials/nav.ejs index 4307034..2d0c144 100644 --- a/apps/web/src/views/partials/nav.ejs +++ b/apps/web/src/views/partials/nav.ejs @@ -2,6 +2,8 @@ PingQL
Monitors + Status pages + Incidents Notifications Settings Logout diff --git a/apps/web/src/views/status-page-edit.ejs b/apps/web/src/views/status-page-edit.ejs new file mode 100644 index 0000000..821d6d0 --- /dev/null +++ b/apps/web/src/views/status-page-edit.ejs @@ -0,0 +1,109 @@ +<%~ include('./partials/head', { title: it.isNew ? 'New status page' : 'Edit status page' }) %> +<%~ include('./partials/nav', { nav: 'status-pages' }) %> + +<% + const p = it.page || {}; + const allMonitors = it.allMonitors || []; + const attached = new Set((it.page?.monitors || []).map(m => m.monitor_id)); +%> + +
+ +
+ ← Back to status pages +

<%= it.isNew ? 'New status page' : 'Edit status page' %>

+
+ +
+ +
+ + +

Public URL: status.pingql.com/

+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + <% if (allMonitors.length === 0) { %> +

No monitors yet. Create one first.

+ <% } else { %> +
+ <% allMonitors.forEach(function(m) { %> + + <% }) %> +
+ <% } %> +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
diff --git a/apps/web/src/views/status-pages.ejs b/apps/web/src/views/status-pages.ejs new file mode 100644 index 0000000..039fc23 --- /dev/null +++ b/apps/web/src/views/status-pages.ejs @@ -0,0 +1,42 @@ +<%~ include('./partials/head', { title: 'Status pages' }) %> +<%~ include('./partials/nav', { nav: 'status-pages' }) %> + +
+ +
+

Status pages

+ + New page +
+ +

+ Public pages people can visit during an outage. Each page picks a slug, the monitors to display, and optional branding. +

+ + <% if (!it.pages || it.pages.length === 0) { %> +
+ No status pages yet. Create one to get a public URL like status.pingql.com/your-slug. +
+ <% } else { %> + <% it.pages.forEach(function(p) { %> +
+
+
+
+

<%= p.title %>

+ <%= p.theme %> +
+ status.pingql.com/<%= p.slug %> + <% if (p.description) { %>

<%= p.description %>

<% } %> +
+
+ Edit +
+ +
+
+
+
+ <% }) %> + <% } %> + +
diff --git a/deploy.sh b/deploy.sh index dea7c56..3e26d28 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,9 +1,11 @@ #!/bin/bash # PingQL Deploy Script -# Usage: ./deploy.sh [web|api|pay|monitor|db|nuke-db|all] [...] +# Usage: ./deploy.sh [web|api|pay|status|monitor|db|nuke-db|all] [...] # Example: ./deploy.sh web api # Example: ./deploy.sh all # Example: ./deploy.sh nuke-db (wipes all data — NOT included in "all") +# +# Note: status (the public status pages service) currently shares the web host. set -e @@ -76,6 +78,21 @@ deploy_web() { REMOTE } +deploy_status() { + # Public status pages service. Co-located on the web host for now; promote to + # its own VPS once traffic justifies it. + echo "[status] Deploying to web-eu-central (co-located)..." + $SSH $WEB_HOST bash << 'REMOTE' + cd /opt/pingql + git pull + cd apps/status + /root/.bun/bin/bun install + systemctl restart pingql-status + systemctl restart caddy + echo "Status deployed and restarted" +REMOTE +} + deploy_monitor() { echo "[monitor] Deploying to all 4 monitors in parallel..." for host in "${MONITOR_HOSTS[@]}"; do @@ -97,8 +114,8 @@ REMOTE # Parse args — supports both "./deploy.sh web api" and "./deploy.sh web,api" if [ $# -eq 0 ]; then - echo "Usage: $0 [web|api|pay|monitor|db|all] [...]" - echo " $0 web,api,pay (comma-separated)" + echo "Usage: $0 [web|api|pay|status|monitor|db|all] [...]" + echo " $0 web,api,status (comma-separated)" exit 1 fi @@ -120,11 +137,12 @@ deploy_target() { api) deploy_api ;; pay) deploy_pay ;; web) deploy_web ;; + status) deploy_status ;; monitor) deploy_monitor ;; nuke-db) nuke_db ;; sync) sync_time ;; - all) deploy_db; deploy_api; deploy_pay; deploy_web; deploy_monitor ;; - *) echo "Unknown target: $1 (valid: web, api, pay, monitor, db, nuke-db, sync, all)"; exit 1 ;; + all) deploy_db; deploy_api; deploy_pay; deploy_web; deploy_status; deploy_monitor ;; + *) echo "Unknown target: $1 (valid: web, api, pay, status, monitor, db, nuke-db, sync, all)"; exit 1 ;; esac }