refactor tier 3
This commit is contained in:
parent
c74ee9856e
commit
5bf02b47d5
|
|
@ -4,6 +4,9 @@ import { monitors } from "./routes/monitors";
|
||||||
import { account } from "./routes/auth";
|
import { account } from "./routes/auth";
|
||||||
import { internal } from "./routes/internal";
|
import { internal } from "./routes/internal";
|
||||||
import { channels } from "./routes/channels";
|
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 { migrate } from "./db";
|
||||||
import { SECURITY_HEADERS } from "../../shared/auth";
|
import { SECURITY_HEADERS } from "../../shared/auth";
|
||||||
|
|
||||||
|
|
@ -17,6 +20,7 @@ process.on("uncaughtException", (err) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await migrate();
|
await migrate();
|
||||||
|
await startRollupJob();
|
||||||
|
|
||||||
const elysia = new Elysia()
|
const elysia = new Elysia()
|
||||||
.get("/", () => ({
|
.get("/", () => ({
|
||||||
|
|
@ -27,6 +31,8 @@ const elysia = new Elysia()
|
||||||
.use(account)
|
.use(account)
|
||||||
.use(monitors)
|
.use(monitors)
|
||||||
.use(channels)
|
.use(channels)
|
||||||
|
.use(statusPages)
|
||||||
|
.use(incidents)
|
||||||
.use(ingest)
|
.use(ingest)
|
||||||
.use(internal);
|
.use(internal);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { requireAuth } from "./auth";
|
||||||
|
import sql from "../db";
|
||||||
|
|
||||||
|
const Status = t.Union([t.Literal("investigating"), t.Literal("identified"), t.Literal("monitoring"), t.Literal("resolved")]);
|
||||||
|
const Severity = t.Union([t.Literal("minor"), t.Literal("major"), t.Literal("critical")]);
|
||||||
|
|
||||||
|
const IncidentBody = t.Object({
|
||||||
|
title: t.String({ minLength: 1, maxLength: 200 }),
|
||||||
|
status: Status,
|
||||||
|
severity: t.Optional(Severity),
|
||||||
|
pinned: t.Optional(t.Boolean()),
|
||||||
|
monitor_ids: t.Optional(t.Array(t.String())),
|
||||||
|
status_page_ids: t.Optional(t.Array(t.String())),
|
||||||
|
initial_update: t.Optional(t.Object({
|
||||||
|
body: t.String({ minLength: 1, maxLength: 10_000 }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const IncidentUpdateBody = t.Object({
|
||||||
|
status: Status,
|
||||||
|
body: t.String({ minLength: 1, maxLength: 10_000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTML escape every byte first, then walk a tiny markdown subset and produce
|
||||||
|
// safe HTML. Anything we didn't explicitly enable stays escaped. Output is
|
||||||
|
// stored in incident_updates.body_html and rendered without further processing.
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(src: string): string {
|
||||||
|
let out = escapeHtml(src.trim());
|
||||||
|
// Inline code first so we don't expand markdown inside it.
|
||||||
|
const codeStash: string[] = [];
|
||||||
|
out = out.replace(/`([^`\n]+?)`/g, (_m, code) => {
|
||||||
|
codeStash.push(code);
|
||||||
|
return `\u0000${codeStash.length - 1}\u0000`;
|
||||||
|
});
|
||||||
|
// Links: [text](http(s)://...)
|
||||||
|
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, text, url) =>
|
||||||
|
`<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"] } });
|
||||||
|
|
@ -19,8 +19,18 @@ const MonitorBody = t.Object({
|
||||||
query: t.Optional(t.Any({ description: "PingQL query — filter conditions for up/down" })),
|
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." })),
|
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." })),
|
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[]) {
|
async function replaceMonitorChannels(monitorId: string, accountId: string, channelIds: string[]) {
|
||||||
await sql`DELETE FROM monitor_notifications WHERE monitor_id = ${monitorId}`;
|
await sql`DELETE FROM monitor_notifications WHERE monitor_id = ${monitorId}`;
|
||||||
if (channelIds.length === 0) return;
|
if (channelIds.length === 0) return;
|
||||||
|
|
@ -39,9 +49,18 @@ async function replaceMonitorChannels(monitorId: string, accountId: string, chan
|
||||||
export const monitors = new Elysia({ prefix: "/monitors" })
|
export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
.use(requireAuth)
|
.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`;
|
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 }) => {
|
.post("/", async ({ accountId, plan, body, set }) => {
|
||||||
const limits = getPlanLimits(plan);
|
const limits = getPlanLimits(plan);
|
||||||
|
|
@ -91,6 +110,7 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
|
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
|
||||||
|
if (body.tags) await replaceMonitorTags(monitor.id, body.tags);
|
||||||
return monitor;
|
return monitor;
|
||||||
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
}, { 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 }[]>`
|
const channels = await sql<{ channel_id: string }[]>`
|
||||||
SELECT channel_id FROM monitor_notifications WHERE monitor_id = ${params.id}
|
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"] } })
|
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
|
||||||
|
|
||||||
.patch("/:id", async ({ accountId, plan, params, body, set }) => {
|
.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 (!monitor) { set.status = 404; return { error: "Not found" }; }
|
||||||
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
|
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
|
||||||
|
if (body.tags) await replaceMonitorTags(monitor.id, body.tags);
|
||||||
return monitor;
|
return monitor;
|
||||||
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"] } });
|
||||||
|
|
@ -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)`;
|
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_monitor ON pings(monitor_id, checked_at DESC)`;
|
||||||
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
|
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>`;
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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}`);
|
||||||
|
|
@ -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?.();
|
||||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -607,6 +607,238 @@ export const dashboard = new Elysia()
|
||||||
return redirect(`/dashboard/monitors/${params.id}`);
|
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("/docs", () => html("docs", {}))
|
||||||
.get("/privacy", () => html("privacy", {}))
|
.get("/privacy", () => html("privacy", {}))
|
||||||
.get("/terms", () => html("tos", {}));
|
.get("/terms", () => html("tos", {}));
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,3 @@
|
||||||
export function sparkline(values: number[], width = 120, height = 32, color = '#60a5fa', region = '__none__'): string {
|
// Re-export from the shared module so apps/web and apps/status share one
|
||||||
if (!values.length) return '';
|
// sparkline implementation.
|
||||||
const max = Math.max(...values, 1);
|
export { sparkline, sparklineFromPings, pickBestRegion } from "../../../shared/render/sparkline";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -482,6 +482,39 @@ Content-Type: application/json
|
||||||
<h3>Status page (HTML)</h3>
|
<h3>Status page (HTML)</h3>
|
||||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -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">← 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
<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">
|
<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/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/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="/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>
|
<a href="/account/logout" class="hover:text-gray-300 transition-colors">Logout</a>
|
||||||
|
|
|
||||||
|
|
@ -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">← 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>
|
||||||
|
|
@ -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>
|
||||||
28
deploy.sh
28
deploy.sh
|
|
@ -1,9 +1,11 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# PingQL Deploy Script
|
# 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 web api
|
||||||
# Example: ./deploy.sh all
|
# Example: ./deploy.sh all
|
||||||
# Example: ./deploy.sh nuke-db (wipes all data — NOT included in "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
|
set -e
|
||||||
|
|
||||||
|
|
@ -76,6 +78,21 @@ deploy_web() {
|
||||||
REMOTE
|
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() {
|
deploy_monitor() {
|
||||||
echo "[monitor] Deploying to all 4 monitors in parallel..."
|
echo "[monitor] Deploying to all 4 monitors in parallel..."
|
||||||
for host in "${MONITOR_HOSTS[@]}"; do
|
for host in "${MONITOR_HOSTS[@]}"; do
|
||||||
|
|
@ -97,8 +114,8 @@ REMOTE
|
||||||
|
|
||||||
# Parse args — supports both "./deploy.sh web api" and "./deploy.sh web,api"
|
# Parse args — supports both "./deploy.sh web api" and "./deploy.sh web,api"
|
||||||
if [ $# -eq 0 ]; then
|
if [ $# -eq 0 ]; then
|
||||||
echo "Usage: $0 [web|api|pay|monitor|db|all] [...]"
|
echo "Usage: $0 [web|api|pay|status|monitor|db|all] [...]"
|
||||||
echo " $0 web,api,pay (comma-separated)"
|
echo " $0 web,api,status (comma-separated)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -120,11 +137,12 @@ deploy_target() {
|
||||||
api) deploy_api ;;
|
api) deploy_api ;;
|
||||||
pay) deploy_pay ;;
|
pay) deploy_pay ;;
|
||||||
web) deploy_web ;;
|
web) deploy_web ;;
|
||||||
|
status) deploy_status ;;
|
||||||
monitor) deploy_monitor ;;
|
monitor) deploy_monitor ;;
|
||||||
nuke-db) nuke_db ;;
|
nuke-db) nuke_db ;;
|
||||||
sync) sync_time ;;
|
sync) sync_time ;;
|
||||||
all) deploy_db; deploy_api; deploy_pay; deploy_web; deploy_monitor ;;
|
all) deploy_db; deploy_api; deploy_pay; deploy_web; deploy_status; deploy_monitor ;;
|
||||||
*) echo "Unknown target: $1 (valid: web, api, pay, monitor, db, nuke-db, sync, all)"; exit 1 ;;
|
*) echo "Unknown target: $1 (valid: web, api, pay, status, monitor, db, nuke-db, sync, all)"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue