refactor tier 3

This commit is contained in:
nate 2026-04-08 15:26:17 +04:00
parent c74ee9856e
commit 5bf02b47d5
30 changed files with 2263 additions and 58 deletions

View File

@ -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);

View File

@ -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<BucketType, string> = {
hourly: "hour",
daily: "day",
weekly: "week",
};
async function rollupCurrent(bucket: BucketType): Promise<number> {
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<number> {
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);
}

View File

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
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) =>
`<a href="${url}" rel="noopener nofollow" target="_blank">${text}</a>`,
);
// Bold then italic.
out = out.replace(/\*\*([^*\n]+?)\*\*/g, "<strong>$1</strong>");
out = out.replace(/\*([^*\n]+?)\*/g, "<em>$1</em>");
// Restore inline code as <code>.
out = out.replace(/\u0000(\d+)\u0000/g, (_m, i) => `<code>${codeStash[Number(i)]}</code>`);
// Paragraphs: split on blank lines, single newlines become <br>.
const paras = out.split(/\n{2,}/).map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`);
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"] } });

View File

@ -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"] } })

View File

@ -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<string> {
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"] } });

View File

@ -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)`;

View File

@ -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 `<svg width="${width}" height="${height}" class="inline-block" data-vals="${values.join(',')}" data-region="${region}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
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<string, number[]> = {};
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);
}

View File

@ -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 `<span class="timestamp" data-ts="${ts}">${text}</span>`;
}

17
apps/status/package.json Normal file
View File

@ -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"
}
}

47
apps/status/src/auth.ts Normal file
View File

@ -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<boolean> {
return await Bun.password.verify(plain, hash);
}

38
apps/status/src/cache.ts Normal file
View File

@ -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<T> { value: T; expires: number }
const store = new Map<string, Entry<unknown>>();
export function cacheGet<T>(key: string): T | null {
const entry = store.get(key) as Entry<T> | undefined;
if (!entry) return null;
if (Date.now() > entry.expires) {
store.delete(key);
return null;
}
return entry.value;
}
export function cacheSet<T>(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<T>(key: string, ttlSeconds: number, producer: () => Promise<T>): Promise<T> {
const hit = cacheGet<T>(key);
if (hit !== null) return hit;
const value = await producer();
cacheSet(key, value, ttlSeconds);
return value;
}

260
apps/status/src/data.ts Normal file
View File

@ -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<Window, { bucket: BucketType; count: number }> = {
"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<StatusPageRow | null> {
const [row] = await sql<StatusPageRow[]>`SELECT * FROM status_pages WHERE slug = ${slug}`;
return row ?? null;
}
export async function loadGroups(pageId: string): Promise<GroupRow[]> {
return sql<GroupRow[]>`
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<MonitorRow[]> {
// Step 1: page → monitors with display overrides + group + position.
const monitorRows = await sql<any[]>`
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<string, MonitorRow["region_states"]> = {};
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<any[]>`
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<string, MonitorRow["buckets"]> = {};
const latencyByMonitor: Record<string, { sum: number; n: number }> = {};
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<any[]>`
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<string, MonitorRow["latency_history"]> = {};
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<any[]>`
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<any[]>`
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<string, string> = {};
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<StatusPageRow, "password_hash"> & { has_password: boolean };
groups: GroupRow[];
monitors: MonitorRow[];
incidents: { active: IncidentSummary[]; recent: IncidentSummary[] };
generated_at: string;
}
export async function loadPagePayload(slug: string, window?: Window): Promise<PagePayload | null> {
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(),
};
}

11
apps/status/src/db.ts Normal file
View File

@ -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;

172
apps/status/src/index.ts Normal file
View File

@ -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<string, string> = {
"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}`);

View File

@ -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<string, Bucket>();
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?.();

View File

@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="${total.toFixed(0)}" height="20" role="img" aria-label="${label}: ${message}">
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="${total.toFixed(0)}" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="${labelW.toFixed(0)}" height="20" fill="#555"/>
<rect x="${labelW.toFixed(0)}" width="${messageW.toFixed(0)}" height="20" fill="${color}"/>
<rect width="${total.toFixed(0)}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="${(labelW / 2).toFixed(1)}" y="14">${escapeXml(label)}</text>
<text x="${(labelW + messageW / 2).toFixed(1)}" y="14">${escapeXml(message)}</text>
</g>
</svg>`;
}
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}

View File

@ -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<string> {
const updates = await sql<any[]>`
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) => `
<item>
<guid isPermaLink="false">${it.guid}</guid>
<title>${escapeXml(it.title)}</title>
<link>${escapeXml(it.link)}</link>
<pubDate>${it.pubDate}</pubDate>
<description><![CDATA[${it.body_html}]]></description>
</item>`).join("");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${channelTitle}</title>
<link>${escapeXml(channelLink)}</link>
<description>${channelDescription}</description>
<ttl>300</ttl>
${itemsXml}
</channel>
</rss>`;
}
function escapeXml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}

View File

@ -0,0 +1,4 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>Not found</title>
<style>body { background:#0a0a0a; color:#94a3b8; font-family:-apple-system, sans-serif; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; } .card{text-align:center} h1{color:#f1f5f9; margin:0 0 0.5rem; font-size:1.5rem} a{color:#38bdf8;text-decoration:none}</style>
</head><body><div class="card"><h1>Status page not found</h1><p>The page you're looking for doesn't exist.</p><p><a href="https://pingql.com">PingQL</a></p></div></body></html>

View File

@ -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';
%><!DOCTYPE html>
<html lang="en" class="<%= themeClass %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= page.title %></title>
<% if (page.description) { %><meta name="description" content="<%= page.description %>"><% } %>
<% if (!page.index_search) { %><meta name="robots" content="noindex,nofollow"><% } %>
<meta property="og:title" content="<%= page.title %>">
<% if (page.description) { %><meta property="og:description" content="<%= page.description %>"><% } %>
<% if (page.og_image_url) { %><meta property="og:image" content="<%= page.og_image_url %>"><% } %>
<link rel="alternate" type="application/rss+xml" title="<%= page.title %> incidents" href="/<%= page.slug %>.rss">
<link rel="manifest" href="/<%= page.slug %>/manifest.json">
<style>
:root {
--bg: #ffffff; --fg: #0f172a; --muted: #64748b; --card: #f8fafc;
--border: #e2e8f0; --accent: #0ea5e9; --green: #10b981; --red: #ef4444; --amber: #f59e0b;
}
html.dark, html:not(.light):not(.dark) { color-scheme: dark; }
@media (prefers-color-scheme: dark) {
html:not(.light) {
--bg: #0a0a0a; --fg: #f1f5f9; --muted: #94a3b8; --card: #111827;
--border: #1f2937; --accent: #38bdf8;
}
}
html.dark {
--bg: #0a0a0a; --fg: #f1f5f9; --muted: #94a3b8; --card: #111827;
--border: #1f2937; --accent: #38bdf8;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; line-height: 1.5; }
main { max-width: 880px; margin: 0 auto; padding: 3rem 1.5rem; }
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; }
.muted { color: var(--muted); font-size: 0.875rem; }
.overall { padding: 1.25rem 1.5rem; border-radius: 12px; color: white; font-weight: 600; font-size: 1.05rem; margin: 1.5rem 0 2rem; display: flex; align-items: center; gap: 0.75rem; }
.overall .dot { width: 12px; height: 12px; border-radius: 50%; background: white; }
.group-title { font-size: 0.85rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin: 2rem 0 0.75rem; }
.monitors { display: flex; flex-direction: column; gap: 0.5rem; }
.monitor { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 1rem 1.25rem; }
.monitor-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
.monitor-name { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
.monitor-name .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.monitor-name .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.monitor-meta { display: flex; gap: 1rem; align-items: center; font-size: 0.85rem; color: var(--muted); }
.uptime-pct { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--fg); }
.bars { display: flex; gap: 2px; height: 32px; margin-top: 0.5rem; align-items: stretch; }
.bar { flex: 1; min-width: 0; border-radius: 2px; }
.regions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; font-size: 0.75rem; }
.region { padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--border); }
.region.up { color: var(--green); border-color: rgba(16,185,129,0.3); }
.region.down { color: var(--red); border-color: rgba(239,68,68,0.3); }
.incidents { margin-bottom: 2rem; }
.incident { background: var(--card); border-left: 4px solid var(--amber); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
.incident.critical { border-left-color: var(--red); }
.incident.major { border-left-color: var(--amber); }
.incident-title { font-weight: 600; margin-bottom: 0.25rem; }
.incident-meta { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.5rem; }
.incident-body p { margin: 0.5rem 0; }
.incident-body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.past-incidents { margin-top: 3rem; }
.past-incidents h2 { font-size: 1.1rem; margin-bottom: 1rem; }
.past { padding: 0.75rem 0; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; gap: 1rem; }
.past-title { font-weight: 500; }
.past-meta { color: var(--muted); font-size: 0.8rem; }
footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
<% if (page.custom_css) { %><%~ page.custom_css %><% } %>
</style>
<% if (page.analytics_html) { %><%~ page.analytics_html %><% } %>
</head>
<body>
<main>
<h1><%= page.title %></h1>
<% if (page.description) { %><div class="muted"><%= page.description %></div><% } %>
<div class="overall" style="background: <%= overallColor %>;">
<span class="dot"></span>
<span><%= overallText %></span>
</div>
<% if (incidents.active.length > 0) { %>
<div class="incidents">
<% incidents.active.forEach(function(i) { %>
<div id="incident-<%= i.id %>" class="incident <%= i.severity %>">
<div class="incident-title"><%= i.title %></div>
<div class="incident-meta"><%= i.status %> · started <%= new Date(i.started_at).toLocaleString() %></div>
<% if (i.latest_update_html) { %><div class="incident-body"><%~ i.latest_update_html %></div><% } %>
</div>
<% }); %>
</div>
<% } %>
<% 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) { %><div class="group-title"><%= groupName %></div><% } %>
<div class="monitors">
<% list.forEach(function(m) { %>
<div class="monitor">
<div class="monitor-head">
<div class="monitor-name">
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
<span class="name"><%= m.display_name %></span>
</div>
<div class="monitor-meta">
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
<span class="uptime-pct"><%= fmtPct(m.uptime_pct) %></span>
</div>
</div>
<% if (m.buckets && m.buckets.length > 0) { %>
<div class="bars" title="<%= statusLabel(m.current_state) %>">
<% m.buckets.forEach(function(b) { %>
<div class="bar" style="background: <%= bucketColor(b) %>;"></div>
<% }); %>
</div>
<% } %>
<% if (m.region_states && m.region_states.length > 1) { %>
<div class="regions">
<% m.region_states.forEach(function(r) { %>
<span class="region <%= r.state %>"><%= r.region %></span>
<% }); %>
</div>
<% } %>
</div>
<% }); %>
</div>
<% }); %>
<% if (incidents.recent.length > 0) { %>
<div class="past-incidents">
<h2>Past incidents</h2>
<% incidents.recent.forEach(function(i) { %>
<div class="past">
<div>
<div class="past-title"><%= i.title %></div>
<div class="past-meta"><%= i.status %> · <%= new Date(i.started_at).toLocaleDateString() %></div>
</div>
</div>
<% }); %>
</div>
<% } %>
<footer>
<% if (page.footer_text) { %><div><%= page.footer_text %></div><% } %>
<% if (page.show_powered_by) { %><div>Status powered by <a href="https://pingql.com">PingQL</a></div><% } %>
</footer>
</main>
<% if (page.auto_refresh_s > 0) { %>
<script>
// Auto-refresh data without a full page reload. Polls /<slug>.json and
// patches just the bar/uptime/dot DOM nodes.
(function() {
const slug = <%~ JSON.stringify(page.slug) %>;
const intervalMs = Math.max(10, <%= page.auto_refresh_s %>) * 1000;
async function refresh() {
try {
const r = await fetch('/' + slug + '.json', { cache: 'no-store' });
if (!r.ok) return;
// Reload on next idle for simplicity. The JSON payload is already cached
// server-side; the visible diff is small.
location.reload();
} catch {}
}
setTimeout(refresh, intervalMs);
})();
</script>
<% } %>
</body>
</html>

View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title><%= it.title %> — Password required</title>
<style>
body { background: #0a0a0a; color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
.card { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; }
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
p { color: #94a3b8; font-size: 0.875rem; margin: 0 0 1.5rem; }
input { width: 100%; padding: 0.75rem 1rem; background: #0a0a0a; border: 1px solid #374151; border-radius: 8px; color: #f1f5f9; font-size: 1rem; box-sizing: border-box; }
input:focus { outline: none; border-color: #38bdf8; }
button { width: 100%; margin-top: 1rem; padding: 0.75rem; background: #0ea5e9; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 1rem; }
button:hover { background: #0284c7; }
.err { color: #ef4444; font-size: 0.85rem; margin-top: 0.75rem; }
</style>
</head>
<body>
<div class="card">
<h1><%= it.title %></h1>
<p>This status page is password protected.</p>
<form method="POST" action="/<%= it.slug %>/auth">
<input type="password" name="password" placeholder="Password" autofocus required>
<button type="submit">Unlock</button>
<% if (it.error) { %><div class="err"><%= it.error %></div><% } %>
</form>
</div>
</body>
</html>

16
apps/status/tsconfig.json Normal file
View File

@ -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"]
}

View File

@ -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", {}));

View File

@ -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 `<svg width="${width}" height="${height}" class="inline-block" data-vals="${values.join(',')}" data-region="${region}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
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<string, number[]> = {};
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";

View File

@ -482,6 +482,39 @@ Content-Type: application/json
<h3>Status page (HTML)</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <span class="o">"$select"</span>: { <span class="s">".status-indicator"</span>: { <span class="o">"$eq"</span>: <span class="s">"All systems operational"</span> } } }</pre></div>
<h3>Page down if a JSON queue is backed up</h3>
<p>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.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$consider"</span>: <span class="s">"down"</span>,
<span class="o">"$json"</span>: { <span class="s">"$.queue.depth"</span>: { <span class="o">"$gt"</span>: <span class="n">1000</span> } }
}</pre></div>
<h3>Down if any signal looks bad</h3>
<p>Compose multiple conditions with <code>$or</code> and flip the result with <code>$consider</code>. Each condition can mix status, body, headers, JSON, and CSS-selector checks freely.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$consider"</span>: <span class="s">"down"</span>,
<span class="o">"$or"</span>: [
{ <span class="k">"status"</span>: { <span class="o">"$gte"</span>: <span class="n">500</span> } },
{ <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">3000</span> } },
{ <span class="o">"$json"</span>: { <span class="s">"$.healthy"</span>: { <span class="o">"$eq"</span>: <span class="n">false</span> } } },
{ <span class="o">"$select"</span>: { <span class="s">".error-banner"</span>: { <span class="o">"$exists"</span>: <span class="n">true</span> } } }
]
}</pre></div>
<h3>Up only when everything matches</h3>
<p>Combine <code>$and</code> with header, body, and JSON checks for a strict definition of healthy.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$and"</span>: [
{ <span class="k">"status"</span>: <span class="n">200</span> },
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$contains"</span>: <span class="s">"application/json"</span> } },
{ <span class="o">"$json"</span>: { <span class="s">"$.version"</span>: { <span class="o">"$startsWith"</span>: <span class="s">"v2"</span> } } },
{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }
]
}</pre></div>
</div>
</main>

View File

@ -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 || [];
%>
<main class="max-w-3xl mx-auto px-8 py-10 space-y-6">
<div>
<a href="/dashboard/incidents" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">&larr; Back to incidents</a>
<h1 class="text-xl font-semibold text-white mt-2"><%= it.isNew ? 'New incident' : 'Edit incident' %></h1>
</div>
<form method="POST" action="<%= it.isNew ? '/dashboard/incidents/new' : '/dashboard/incidents/' + i.id + '/edit' %>" class="space-y-5 card-static p-6">
<div>
<label class="block text-sm text-gray-400 mb-1.5">Title</label>
<input name="title" type="text" required value="<%= i.title || '' %>" placeholder="API latency degraded"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
</div>
<div class="flex gap-4">
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Status</label>
<select name="status" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% ['investigating','identified','monitoring','resolved'].forEach(function(s) { %>
<option value="<%= s %>" <%= (i.status || 'investigating') === s ? 'selected' : '' %>><%= s %></option>
<% }) %>
</select>
</div>
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Severity</label>
<select name="severity" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% ['minor','major','critical'].forEach(function(s) { %>
<option value="<%= s %>" <%= (i.severity || 'minor') === s ? 'selected' : '' %>><%= s %></option>
<% }) %>
</select>
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Affected monitors</label>
<% if (allMonitors.length === 0) { %>
<p class="text-xs text-gray-600">No monitors yet.</p>
<% } else { %>
<div class="flex flex-wrap gap-2">
<% allMonitors.forEach(function(m) { %>
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= attachedMonitors.has(m.id) ? 'checked' : '' %>>
<span class="text-sm text-gray-300"><%= m.name %></span>
</label>
<% }) %>
</div>
<% } %>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Show on status pages</label>
<% if (allPages.length === 0) { %>
<p class="text-xs text-gray-600">No status pages yet. <a href="/dashboard/status-pages/new" class="text-blue-400 hover:text-blue-300">Create one</a>.</p>
<% } else { %>
<div class="flex flex-wrap gap-2">
<% allPages.forEach(function(p) { %>
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
<input type="checkbox" name="status_page_ids" value="<%= p.id %>" class="accent-blue-500" <%= attachedPages.has(p.id) ? 'checked' : '' %>>
<span class="text-sm text-gray-300"><%= p.title %></span>
</label>
<% }) %>
</div>
<% } %>
</div>
<% if (it.isNew) { %>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Initial update</label>
<textarea name="initial_update_body" rows="4" placeholder="We're investigating reports of slow API responses." required
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500"></textarea>
<p class="text-xs text-gray-600 mt-1">Markdown supported: **bold**, *italic*, `code`, [link](https://...)</p>
</div>
<% } %>
<button type="submit" class="btn-primary px-6 py-2.5 text-sm"><%= it.isNew ? 'Create incident' : 'Save changes' %></button>
</form>
<% if (!it.isNew && updates.length > 0) { %>
<section class="card-static p-6">
<h2 class="text-sm font-semibold text-gray-300 mb-4">Timeline</h2>
<div class="space-y-4">
<% updates.slice().reverse().forEach(function(u) { %>
<div class="border-l-2 border-border-subtle pl-4">
<div class="text-xs text-gray-500 mb-1"><span class="text-gray-400 font-medium"><%= u.status %></span> · <%~ it.timeAgoSSR(u.created_at) %></div>
<div class="text-sm text-gray-300 incident-body"><%~ u.body_html %></div>
</div>
<% }) %>
</div>
</section>
<% } %>
<% if (!it.isNew) { %>
<section class="card-static p-6">
<h2 class="text-sm font-semibold text-gray-300 mb-4">Post update</h2>
<form method="POST" action="/dashboard/incidents/<%= i.id %>/update" class="space-y-3">
<select name="status" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% ['investigating','identified','monitoring','resolved'].forEach(function(s) { %>
<option value="<%= s %>" <%= (i.status || 'investigating') === s ? 'selected' : '' %>><%= s %></option>
<% }) %>
</select>
<textarea name="body" rows="3" placeholder="Update text" required
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500"></textarea>
<button type="submit" class="btn-primary px-6 py-2 text-sm">Post update</button>
</form>
</section>
<% } %>
</main>

View File

@ -0,0 +1,46 @@
<%~ include('./partials/head', { title: 'Incidents' }) %>
<%~ include('./partials/nav', { nav: 'incidents' }) %>
<main class="max-w-3xl mx-auto px-8 py-10 space-y-8">
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-white">Incidents</h1>
<a href="/dashboard/incidents/new" class="btn-primary inline-flex items-center gap-2 px-4 py-2 text-sm">+ New incident</a>
</div>
<p class="text-sm text-gray-500 leading-relaxed">
Manually-posted incidents that show up on attached status pages with a timeline of updates.
</p>
<% if (!it.incidents || it.incidents.length === 0) { %>
<section class="card-static p-6 text-sm text-gray-500">No incidents yet.</section>
<% } 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';
%>
<section class="card-static p-6">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h2 class="text-sm font-semibold text-gray-200 truncate"><%= i.title %></h2>
<span class="text-xs px-2 py-0.5 rounded-full border <%= sevColor %>"><%= i.severity %></span>
<span class="text-xs px-2 py-0.5 rounded-full border <%= statusColor %>"><%= i.status %></span>
</div>
<div class="text-xs text-gray-500">Started <%~ it.timeAgoSSR(i.started_at) %><% if (i.resolved_at) { %> · Resolved <%~ it.timeAgoSSR(i.resolved_at) %><% } %></div>
</div>
<div class="flex items-center gap-2 shrink-0">
<a href="/dashboard/incidents/<%= i.id %>" class="px-3 py-1.5 rounded-lg border border-border-subtle text-gray-400 hover:text-gray-200 text-xs transition-colors">Open</a>
<form action="/dashboard/incidents/<%= i.id %>/delete" method="POST" class="inline" onsubmit="return confirm('Delete this incident?')">
<button type="submit" class="px-3 py-1.5 rounded-lg border border-red-900/30 text-red-400 hover:bg-red-900/20 hover:border-red-800/40 text-xs transition-colors">Delete</button>
</form>
</div>
</div>
</section>
<% }) %>
<% } %>
</main>

View File

@ -2,6 +2,8 @@
<a href="/dashboard/home" class="text-xl font-bold tracking-tight group">Ping<span class="text-blue-400 transition-all group-hover:drop-shadow-[0_0_8px_rgba(59,130,246,0.4)]">QL</span></a>
<div class="flex items-center gap-5 text-sm text-gray-500">
<a href="/dashboard/home" class="<%= it.nav === 'monitors' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Monitors</a>
<a href="/dashboard/status-pages" class="<%= it.nav === 'status-pages' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Status pages</a>
<a href="/dashboard/incidents" class="<%= it.nav === 'incidents' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Incidents</a>
<a href="/dashboard/notifications" class="<%= it.nav === 'notifications' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Notifications</a>
<a href="/dashboard/settings" class="<%= it.nav === 'settings' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Settings</a>
<a href="/account/logout" class="hover:text-gray-300 transition-colors">Logout</a>

View File

@ -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));
%>
<main class="max-w-3xl mx-auto px-8 py-10">
<div class="mb-6">
<a href="/dashboard/status-pages" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">&larr; Back to status pages</a>
<h1 class="text-xl font-semibold text-white mt-2"><%= it.isNew ? 'New status page' : 'Edit status page' %></h1>
</div>
<form method="POST" action="<%= it.isNew ? '/dashboard/status-pages/new' : '/dashboard/status-pages/' + p.id + '/edit' %>" class="space-y-6">
<div>
<label class="block text-sm text-gray-400 mb-1.5">Slug</label>
<input name="slug" type="text" required value="<%= p.slug || '' %>" placeholder="my-app" pattern="^[a-z0-9][a-z0-9-]*$"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm">
<p class="text-xs text-gray-600 mt-1">Public URL: <span class="text-blue-400 font-mono">status.pingql.com/<your-slug></span></p>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Title</label>
<input name="title" type="text" required value="<%= p.title || '' %>" placeholder="My App Status"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Description <span class="text-gray-600">(optional)</span></label>
<textarea name="description" rows="2" placeholder="What this status page covers"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500"><%= p.description || '' %></textarea>
</div>
<div class="flex gap-4">
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Theme</label>
<select name="theme" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% ['auto','light','dark'].forEach(function(t) { %>
<option value="<%= t %>" <%= (p.theme || 'auto') === t ? 'selected' : '' %>><%= t %></option>
<% }) %>
</select>
</div>
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Default window</label>
<select name="default_window" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% ['24h','7d','30d','90d'].forEach(function(w) { %>
<option value="<%= w %>" <%= (p.default_window || '24h') === w ? 'selected' : '' %>><%= w %></option>
<% }) %>
</select>
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Monitors</label>
<% if (allMonitors.length === 0) { %>
<p class="text-xs text-gray-600">No monitors yet. <a href="/dashboard/monitors/new" class="text-blue-400 hover:text-blue-300">Create one</a> first.</p>
<% } else { %>
<div class="flex flex-wrap gap-2">
<% allMonitors.forEach(function(m) { %>
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= attached.has(m.id) ? 'checked' : '' %>>
<span class="text-sm text-gray-300"><%= m.name %></span>
</label>
<% }) %>
</div>
<% } %>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2 text-sm text-gray-400">
<input type="checkbox" name="show_response_time" value="1" <%= (p.show_response_time !== false) ? 'checked' : '' %> class="accent-blue-500">
Show response time
</label>
<label class="flex items-center gap-2 text-sm text-gray-400">
<input type="checkbox" name="show_powered_by" value="1" <%= (p.show_powered_by !== false) ? 'checked' : '' %> class="accent-blue-500">
Show "Powered by PingQL"
</label>
<label class="flex items-center gap-2 text-sm text-gray-400">
<input type="checkbox" name="index_search" value="1" <%= (p.index_search !== false) ? 'checked' : '' %> class="accent-blue-500">
Allow search engines
</label>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Password <span class="text-gray-600">(optional, leave blank to remove)</span></label>
<input name="password" type="password" placeholder="Leave blank for public access"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Custom CSS <span class="text-gray-600">(optional)</span></label>
<textarea name="custom_css" rows="4" placeholder=":root { --accent: #ff00ff; }"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-xs"><%= p.custom_css || '' %></textarea>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Footer text <span class="text-gray-600">(optional)</span></label>
<input name="footer_text" type="text" value="<%= p.footer_text || '' %>" placeholder="Contact us at support@example.com"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
</div>
<button type="submit" class="btn-primary px-6 py-2.5 text-sm"><%= it.isNew ? 'Create page' : 'Save changes' %></button>
</form>
</main>

View File

@ -0,0 +1,42 @@
<%~ include('./partials/head', { title: 'Status pages' }) %>
<%~ include('./partials/nav', { nav: 'status-pages' }) %>
<main class="max-w-3xl mx-auto px-8 py-10 space-y-8">
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-white">Status pages</h1>
<a href="/dashboard/status-pages/new" class="btn-primary inline-flex items-center gap-2 px-4 py-2 text-sm">+ New page</a>
</div>
<p class="text-sm text-gray-500 leading-relaxed">
Public pages people can visit during an outage. Each page picks a slug, the monitors to display, and optional branding.
</p>
<% if (!it.pages || it.pages.length === 0) { %>
<section class="card-static p-6 text-sm text-gray-500">
No status pages yet. Create one to get a public URL like <code class="text-blue-400">status.pingql.com/your-slug</code>.
</section>
<% } else { %>
<% it.pages.forEach(function(p) { %>
<section class="card-static p-6">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h2 class="text-sm font-semibold text-gray-200 truncate"><%= p.title %></h2>
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-800/50 border border-border-subtle text-gray-400 font-mono"><%= p.theme %></span>
</div>
<a href="https://status.pingql.com/<%= p.slug %>" target="_blank" class="text-xs text-blue-400 hover:text-blue-300 font-mono break-all">status.pingql.com/<%= p.slug %></a>
<% if (p.description) { %><p class="text-xs text-gray-500 mt-1"><%= p.description %></p><% } %>
</div>
<div class="flex items-center gap-2 shrink-0">
<a href="/dashboard/status-pages/<%= p.id %>" class="px-3 py-1.5 rounded-lg border border-border-subtle text-gray-400 hover:text-gray-200 text-xs transition-colors">Edit</a>
<form action="/dashboard/status-pages/<%= p.id %>/delete" method="POST" class="inline" onsubmit="return confirm('Delete status page \'<%= p.title %>\'? This cannot be undone.')">
<button type="submit" class="px-3 py-1.5 rounded-lg border border-red-900/30 text-red-400 hover:bg-red-900/20 hover:border-red-800/40 text-xs transition-colors">Delete</button>
</form>
</div>
</div>
</section>
<% }) %>
<% } %>
</main>

View File

@ -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
}