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 { internal } from "./routes/internal";
|
||||
import { channels } from "./routes/channels";
|
||||
import { statusPages } from "./routes/status_pages";
|
||||
import { incidents } from "./routes/incidents";
|
||||
import { startRollupJob } from "./jobs/rollup";
|
||||
import { migrate } from "./db";
|
||||
import { SECURITY_HEADERS } from "../../shared/auth";
|
||||
|
||||
|
|
@ -17,6 +20,7 @@ process.on("uncaughtException", (err) => {
|
|||
});
|
||||
|
||||
await migrate();
|
||||
await startRollupJob();
|
||||
|
||||
const elysia = new Elysia()
|
||||
.get("/", () => ({
|
||||
|
|
@ -27,6 +31,8 @@ const elysia = new Elysia()
|
|||
.use(account)
|
||||
.use(monitors)
|
||||
.use(channels)
|
||||
.use(statusPages)
|
||||
.use(incidents)
|
||||
.use(ingest)
|
||||
.use(internal);
|
||||
|
||||
|
|
|
|||
|
|
@ -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" })),
|
||||
regions: t.Optional(t.Array(t.String(), { description: "Regions to run checks from. Empty array = all regions." })),
|
||||
channel_ids: t.Optional(t.Array(t.String(), { description: "Notification channel IDs to attach to this monitor." })),
|
||||
tags: t.Optional(t.Array(t.String({ pattern: "^[a-z0-9][a-z0-9-]{0,40}$" }), { description: "Lowercase tag slugs for grouping. Replaces the existing tag set." })),
|
||||
});
|
||||
|
||||
async function replaceMonitorTags(monitorId: string, tags: string[]) {
|
||||
await sql`DELETE FROM monitor_tags WHERE monitor_id = ${monitorId}`;
|
||||
if (tags.length === 0) return;
|
||||
const unique = Array.from(new Set(tags.map((t) => t.trim()).filter(Boolean)));
|
||||
if (unique.length === 0) return;
|
||||
const rows = unique.map((tag) => ({ monitor_id: monitorId, tag }));
|
||||
await sql`INSERT INTO monitor_tags ${sql(rows, "monitor_id", "tag")}`;
|
||||
}
|
||||
|
||||
async function replaceMonitorChannels(monitorId: string, accountId: string, channelIds: string[]) {
|
||||
await sql`DELETE FROM monitor_notifications WHERE monitor_id = ${monitorId}`;
|
||||
if (channelIds.length === 0) return;
|
||||
|
|
@ -39,9 +49,18 @@ async function replaceMonitorChannels(monitorId: string, accountId: string, chan
|
|||
export const monitors = new Elysia({ prefix: "/monitors" })
|
||||
.use(requireAuth)
|
||||
|
||||
.get("/", async ({ accountId }) => {
|
||||
.get("/", async ({ accountId, query }) => {
|
||||
const tag = (query as any)?.tag;
|
||||
if (tag) {
|
||||
return sql`
|
||||
SELECT m.* FROM monitors m
|
||||
JOIN monitor_tags mt ON mt.monitor_id = m.id
|
||||
WHERE m.account_id = ${accountId} AND mt.tag = ${tag}
|
||||
ORDER BY m.created_at DESC
|
||||
`;
|
||||
}
|
||||
return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
||||
}, { detail: { summary: "List monitors", tags: ["monitors"] } })
|
||||
}, { detail: { summary: "List monitors (optional ?tag= filter)", tags: ["monitors"] } })
|
||||
|
||||
.post("/", async ({ accountId, plan, body, set }) => {
|
||||
const limits = getPlanLimits(plan);
|
||||
|
|
@ -91,6 +110,7 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
|||
RETURNING *
|
||||
`;
|
||||
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
|
||||
if (body.tags) await replaceMonitorTags(monitor.id, body.tags);
|
||||
return monitor;
|
||||
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
||||
|
||||
|
|
@ -107,7 +127,10 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
|||
const channels = await sql<{ channel_id: string }[]>`
|
||||
SELECT channel_id FROM monitor_notifications WHERE monitor_id = ${params.id}
|
||||
`;
|
||||
return { ...monitor, results, channel_ids: channels.map((c) => c.channel_id) };
|
||||
const tagRows = await sql<{ tag: string }[]>`
|
||||
SELECT tag FROM monitor_tags WHERE monitor_id = ${params.id} ORDER BY tag
|
||||
`;
|
||||
return { ...monitor, results, channel_ids: channels.map((c) => c.channel_id), tags: tagRows.map((t) => t.tag) };
|
||||
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
|
||||
|
||||
.patch("/:id", async ({ accountId, plan, params, body, set }) => {
|
||||
|
|
@ -153,6 +176,7 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
|||
`;
|
||||
if (!monitor) { set.status = 404; return { error: "Not found" }; }
|
||||
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
|
||||
if (body.tags) await replaceMonitorTags(monitor.id, body.tags);
|
||||
return monitor;
|
||||
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
||||
|
||||
|
|
|
|||
|
|
@ -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)`;
|
||||
|
||||
// Tier 3: monitor tags. One row per (monitor, tag). Used by the dashboard
|
||||
// home filter and the status page builder's "all monitors with tag X" picker.
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS monitor_tags (
|
||||
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (monitor_id, tag)
|
||||
)
|
||||
`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_monitor_tags_tag ON monitor_tags(tag)`;
|
||||
|
||||
// Tier 3: public status pages. The whole subgraph below is read by the
|
||||
// standalone apps/status service; writes happen via apps/api admin routes.
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS status_pages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
theme TEXT NOT NULL DEFAULT 'auto',
|
||||
password_hash TEXT,
|
||||
index_search BOOLEAN NOT NULL DEFAULT true,
|
||||
show_powered_by BOOLEAN NOT NULL DEFAULT true,
|
||||
show_response_time BOOLEAN NOT NULL DEFAULT true,
|
||||
show_cert_expiry BOOLEAN NOT NULL DEFAULT false,
|
||||
default_window TEXT NOT NULL DEFAULT '24h',
|
||||
custom_css TEXT,
|
||||
footer_text TEXT,
|
||||
og_image_url TEXT,
|
||||
analytics_html TEXT,
|
||||
auto_refresh_s INTEGER NOT NULL DEFAULT 60,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`;
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS status_page_groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_status_page_groups_page ON status_page_groups(status_page_id)`;
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS status_page_monitors (
|
||||
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
|
||||
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
group_id UUID REFERENCES status_page_groups(id) ON DELETE SET NULL,
|
||||
display_name TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (status_page_id, monitor_id)
|
||||
)
|
||||
`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_status_page_monitors_monitor ON status_page_monitors(monitor_id)`;
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS incidents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'minor',
|
||||
pinned BOOLEAN NOT NULL DEFAULT true,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_incidents_account ON incidents(account_id, started_at DESC)`;
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS incident_updates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON incident_updates(incident_id, created_at)`;
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS incident_monitors (
|
||||
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (incident_id, monitor_id)
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS incident_status_pages (
|
||||
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (incident_id, status_page_id)
|
||||
)
|
||||
`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_incident_status_pages_page ON incident_status_pages(status_page_id)`;
|
||||
|
||||
// Shared uptime rollup. One row per (monitor, region, bucket_type, bucket_start).
|
||||
// Powers status page uptime windows AND any future dashboard widgets.
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS monitor_uptime_rollup (
|
||||
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
region TEXT NOT NULL,
|
||||
bucket_type TEXT NOT NULL,
|
||||
bucket_start TIMESTAMPTZ NOT NULL,
|
||||
total INTEGER NOT NULL,
|
||||
up_count INTEGER NOT NULL,
|
||||
avg_latency REAL,
|
||||
PRIMARY KEY (monitor_id, region, bucket_type, bucket_start)
|
||||
)
|
||||
`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_uptime_rollup_lookup ON monitor_uptime_rollup(monitor_id, bucket_type, bucket_start DESC)`;
|
||||
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
})
|
||||
|
||||
// ── Status pages ──────────────────────────────────────────────────
|
||||
.get("/dashboard/status-pages", async ({ cookie, headers }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const pages = await sql`
|
||||
SELECT id, slug, title, description, theme, default_window
|
||||
FROM status_pages WHERE account_id = ${resolved.accountId}
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
return html("status-pages", { nav: "status-pages", pages });
|
||||
})
|
||||
|
||||
.get("/dashboard/status-pages/new", async ({ cookie, headers }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const allMonitors = await sql`
|
||||
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
|
||||
`;
|
||||
return html("status-page-edit", { nav: "status-pages", isNew: true, page: null, allMonitors });
|
||||
})
|
||||
|
||||
.get("/dashboard/status-pages/:id", async ({ cookie, headers, params }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const [page] = await sql`
|
||||
SELECT * FROM status_pages WHERE id = ${params.id} AND account_id = ${resolved.accountId}
|
||||
`;
|
||||
if (!page) return redirect("/dashboard/status-pages");
|
||||
const monitors = await sql`
|
||||
SELECT monitor_id FROM status_page_monitors WHERE status_page_id = ${params.id}
|
||||
`;
|
||||
const allMonitors = await sql`
|
||||
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
|
||||
`;
|
||||
page.monitors = monitors;
|
||||
return html("status-page-edit", { nav: "status-pages", isNew: false, page, allMonitors });
|
||||
})
|
||||
|
||||
.post("/dashboard/status-pages/new", async ({ cookie, headers, body }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const b = body as any;
|
||||
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||
const key = cookie?.pingql_key?.value;
|
||||
await fetch(`${apiUrl}/status-pages/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
||||
body: JSON.stringify({
|
||||
slug: (b.slug || "").trim(),
|
||||
title: b.title,
|
||||
description: b.description || null,
|
||||
theme: b.theme || "auto",
|
||||
default_window: b.default_window || "24h",
|
||||
show_response_time: !!b.show_response_time,
|
||||
show_powered_by: !!b.show_powered_by,
|
||||
index_search: !!b.index_search,
|
||||
password: b.password || undefined,
|
||||
custom_css: b.custom_css || null,
|
||||
footer_text: b.footer_text || null,
|
||||
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i })),
|
||||
}),
|
||||
});
|
||||
} catch {}
|
||||
return redirect("/dashboard/status-pages");
|
||||
})
|
||||
|
||||
.post("/dashboard/status-pages/:id/edit", async ({ cookie, headers, params, body }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const b = body as any;
|
||||
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||
const key = cookie?.pingql_key?.value;
|
||||
const payload: any = {
|
||||
slug: (b.slug || "").trim(),
|
||||
title: b.title,
|
||||
description: b.description || null,
|
||||
theme: b.theme || "auto",
|
||||
default_window: b.default_window || "24h",
|
||||
show_response_time: !!b.show_response_time,
|
||||
show_powered_by: !!b.show_powered_by,
|
||||
index_search: !!b.index_search,
|
||||
custom_css: b.custom_css || null,
|
||||
footer_text: b.footer_text || null,
|
||||
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i })),
|
||||
};
|
||||
// Only send `password` if the user actually typed something. An empty box
|
||||
// means "leave the existing password as-is" — sending null would clear it.
|
||||
if (b.password) payload.password = b.password;
|
||||
await fetch(`${apiUrl}/status-pages/${params.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {}
|
||||
return redirect("/dashboard/status-pages");
|
||||
})
|
||||
|
||||
.post("/dashboard/status-pages/:id/delete", async ({ cookie, headers, params }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||
const key = cookie?.pingql_key?.value;
|
||||
await fetch(`${apiUrl}/status-pages/${params.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${key}` },
|
||||
});
|
||||
} catch {}
|
||||
return redirect("/dashboard/status-pages");
|
||||
})
|
||||
|
||||
// ── Incidents ─────────────────────────────────────────────────────
|
||||
.get("/dashboard/incidents", async ({ cookie, headers }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const incidents = await sql`
|
||||
SELECT id, title, status, severity, pinned, started_at, resolved_at
|
||||
FROM incidents WHERE account_id = ${resolved.accountId}
|
||||
ORDER BY started_at DESC LIMIT 200
|
||||
`;
|
||||
return html("incidents", { nav: "incidents", incidents });
|
||||
})
|
||||
|
||||
.get("/dashboard/incidents/new", async ({ cookie, headers }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const allMonitors = await sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
|
||||
const allPages = await sql`SELECT id, title FROM status_pages WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
|
||||
return html("incident-edit", { nav: "incidents", isNew: true, incident: null, allMonitors, allPages });
|
||||
})
|
||||
|
||||
.get("/dashboard/incidents/:id", async ({ cookie, headers, params }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const [incident] = await sql`
|
||||
SELECT * FROM incidents WHERE id = ${params.id} AND account_id = ${resolved.accountId}
|
||||
`;
|
||||
if (!incident) return redirect("/dashboard/incidents");
|
||||
const updates = await sql`SELECT * FROM incident_updates WHERE incident_id = ${params.id} ORDER BY created_at ASC`;
|
||||
const monitors = await sql`SELECT monitor_id FROM incident_monitors WHERE incident_id = ${params.id}`;
|
||||
const pages = await sql`SELECT status_page_id FROM incident_status_pages WHERE incident_id = ${params.id}`;
|
||||
incident.updates = updates;
|
||||
incident.monitor_ids = monitors.map((m: any) => m.monitor_id);
|
||||
incident.status_page_ids = pages.map((p: any) => p.status_page_id);
|
||||
const allMonitors = await sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
|
||||
const allPages = await sql`SELECT id, title FROM status_pages WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
|
||||
return html("incident-edit", { nav: "incidents", isNew: false, incident, allMonitors, allPages });
|
||||
})
|
||||
|
||||
.post("/dashboard/incidents/new", async ({ cookie, headers, body }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const b = body as any;
|
||||
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
||||
const pageIds = Array.isArray(b.status_page_ids) ? b.status_page_ids : (b.status_page_ids ? [b.status_page_ids] : []);
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||
const key = cookie?.pingql_key?.value;
|
||||
await fetch(`${apiUrl}/incidents/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
||||
body: JSON.stringify({
|
||||
title: b.title,
|
||||
status: b.status || "investigating",
|
||||
severity: b.severity || "minor",
|
||||
monitor_ids: monitorIds,
|
||||
status_page_ids: pageIds,
|
||||
initial_update: { body: b.initial_update_body || "Investigating." },
|
||||
}),
|
||||
});
|
||||
} catch {}
|
||||
return redirect("/dashboard/incidents");
|
||||
})
|
||||
|
||||
.post("/dashboard/incidents/:id/edit", async ({ cookie, headers, params, body }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const b = body as any;
|
||||
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
||||
const pageIds = Array.isArray(b.status_page_ids) ? b.status_page_ids : (b.status_page_ids ? [b.status_page_ids] : []);
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||
const key = cookie?.pingql_key?.value;
|
||||
await fetch(`${apiUrl}/incidents/${params.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
||||
body: JSON.stringify({
|
||||
title: b.title,
|
||||
status: b.status,
|
||||
severity: b.severity,
|
||||
monitor_ids: monitorIds,
|
||||
status_page_ids: pageIds,
|
||||
}),
|
||||
});
|
||||
} catch {}
|
||||
return redirect(`/dashboard/incidents/${params.id}`);
|
||||
})
|
||||
|
||||
.post("/dashboard/incidents/:id/update", async ({ cookie, headers, params, body }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
const b = body as any;
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||
const key = cookie?.pingql_key?.value;
|
||||
await fetch(`${apiUrl}/incidents/${params.id}/updates`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
||||
body: JSON.stringify({ status: b.status, body: b.body }),
|
||||
});
|
||||
} catch {}
|
||||
return redirect(`/dashboard/incidents/${params.id}`);
|
||||
})
|
||||
|
||||
.post("/dashboard/incidents/:id/delete", async ({ cookie, headers, params }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||
const key = cookie?.pingql_key?.value;
|
||||
await fetch(`${apiUrl}/incidents/${params.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${key}` },
|
||||
});
|
||||
} catch {}
|
||||
return redirect("/dashboard/incidents");
|
||||
})
|
||||
|
||||
.get("/docs", () => html("docs", {}))
|
||||
.get("/privacy", () => html("privacy", {}))
|
||||
.get("/terms", () => html("tos", {}));
|
||||
|
|
|
|||
|
|
@ -1,50 +1,3 @@
|
|||
export function sparkline(values: number[], width = 120, height = 32, color = '#60a5fa', region = '__none__'): string {
|
||||
if (!values.length) return '';
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const step = width / Math.max(values.length - 1, 1);
|
||||
const points = values.map((v, i) => {
|
||||
const x = i * step;
|
||||
const y = height - ((v - min) / range) * (height - 4) - 2;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return `<svg width="${width}" height="${height}" class="inline-block" data-vals="${values.join(',')}" data-region="${region}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||
}
|
||||
|
||||
import { REGION_COLORS } from "../../../shared/plans";
|
||||
|
||||
export function pickBestRegion(pings: Array<{latency_ms?: number|null, region?: string|null}>): { region: string, values: number[], latest: number | null } {
|
||||
const withLatency = pings.filter(p => p.latency_ms != null);
|
||||
if (!withLatency.length) return { region: '__none__', values: [], latest: null };
|
||||
|
||||
const byRegion: Record<string, number[]> = {};
|
||||
for (const p of withLatency) {
|
||||
const key = p.region || '__none__';
|
||||
if (!byRegion[key]) byRegion[key] = [];
|
||||
byRegion[key].push(p.latency_ms!);
|
||||
}
|
||||
|
||||
const recentRegions = new Set(
|
||||
withLatency.slice(-3).map(p => p.region || '__none__')
|
||||
);
|
||||
|
||||
let bestRegion = '__none__';
|
||||
let bestAvg = Infinity;
|
||||
for (const [region, vals] of Object.entries(byRegion)) {
|
||||
if (!recentRegions.has(region)) continue;
|
||||
const recent = vals.slice(-3);
|
||||
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
|
||||
if (avg < bestAvg) { bestAvg = avg; bestRegion = region; }
|
||||
}
|
||||
|
||||
const values = byRegion[bestRegion] || [];
|
||||
return { region: bestRegion, values, latest: values.length ? values[values.length - 1] : null };
|
||||
}
|
||||
|
||||
export function sparklineFromPings(pings: Array<{latency_ms?: number|null, region?: string|null}>, width = 120, height = 32): string {
|
||||
const { region, values } = pickBestRegion(pings);
|
||||
if (!values.length) return '';
|
||||
const color = REGION_COLORS[region] || '#60a5fa';
|
||||
return sparkline(values, width, height, color, region);
|
||||
}
|
||||
// Re-export from the shared module so apps/web and apps/status share one
|
||||
// sparkline implementation.
|
||||
export { sparkline, sparklineFromPings, pickBestRegion } from "../../../shared/render/sparkline";
|
||||
|
|
|
|||
|
|
@ -482,6 +482,39 @@ Content-Type: application/json
|
|||
<h3>Status page (HTML)</h3>
|
||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre>{ <span class="o">"$select"</span>: { <span class="s">".status-indicator"</span>: { <span class="o">"$eq"</span>: <span class="s">"All systems operational"</span> } } }</pre></div>
|
||||
|
||||
<h3>Page down if a JSON queue is backed up</h3>
|
||||
<p>The killer demo: alert when a JSON field crosses a threshold. Uptime Kuma's keyword/json-query checks can't compose this — you'd need a script. Here it's one expression.</p>
|
||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre>{
|
||||
<span class="o">"$consider"</span>: <span class="s">"down"</span>,
|
||||
<span class="o">"$json"</span>: { <span class="s">"$.queue.depth"</span>: { <span class="o">"$gt"</span>: <span class="n">1000</span> } }
|
||||
}</pre></div>
|
||||
|
||||
<h3>Down if any signal looks bad</h3>
|
||||
<p>Compose multiple conditions with <code>$or</code> and flip the result with <code>$consider</code>. Each condition can mix status, body, headers, JSON, and CSS-selector checks freely.</p>
|
||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre>{
|
||||
<span class="o">"$consider"</span>: <span class="s">"down"</span>,
|
||||
<span class="o">"$or"</span>: [
|
||||
{ <span class="k">"status"</span>: { <span class="o">"$gte"</span>: <span class="n">500</span> } },
|
||||
{ <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">3000</span> } },
|
||||
{ <span class="o">"$json"</span>: { <span class="s">"$.healthy"</span>: { <span class="o">"$eq"</span>: <span class="n">false</span> } } },
|
||||
{ <span class="o">"$select"</span>: { <span class="s">".error-banner"</span>: { <span class="o">"$exists"</span>: <span class="n">true</span> } } }
|
||||
]
|
||||
}</pre></div>
|
||||
|
||||
<h3>Up only when everything matches</h3>
|
||||
<p>Combine <code>$and</code> with header, body, and JSON checks for a strict definition of healthy.</p>
|
||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre>{
|
||||
<span class="o">"$and"</span>: [
|
||||
{ <span class="k">"status"</span>: <span class="n">200</span> },
|
||||
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$contains"</span>: <span class="s">"application/json"</span> } },
|
||||
{ <span class="o">"$json"</span>: { <span class="s">"$.version"</span>: { <span class="o">"$startsWith"</span>: <span class="s">"v2"</span> } } },
|
||||
{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }
|
||||
]
|
||||
}</pre></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<div class="flex items-center gap-5 text-sm text-gray-500">
|
||||
<a href="/dashboard/home" class="<%= it.nav === 'monitors' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Monitors</a>
|
||||
<a href="/dashboard/status-pages" class="<%= it.nav === 'status-pages' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Status pages</a>
|
||||
<a href="/dashboard/incidents" class="<%= it.nav === 'incidents' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Incidents</a>
|
||||
<a href="/dashboard/notifications" class="<%= it.nav === 'notifications' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Notifications</a>
|
||||
<a href="/dashboard/settings" class="<%= it.nav === 'settings' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Settings</a>
|
||||
<a href="/account/logout" class="hover:text-gray-300 transition-colors">Logout</a>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# PingQL Deploy Script
|
||||
# Usage: ./deploy.sh [web|api|pay|monitor|db|nuke-db|all] [...]
|
||||
# Usage: ./deploy.sh [web|api|pay|status|monitor|db|nuke-db|all] [...]
|
||||
# Example: ./deploy.sh web api
|
||||
# Example: ./deploy.sh all
|
||||
# Example: ./deploy.sh nuke-db (wipes all data — NOT included in "all")
|
||||
#
|
||||
# Note: status (the public status pages service) currently shares the web host.
|
||||
|
||||
set -e
|
||||
|
||||
|
|
@ -76,6 +78,21 @@ deploy_web() {
|
|||
REMOTE
|
||||
}
|
||||
|
||||
deploy_status() {
|
||||
# Public status pages service. Co-located on the web host for now; promote to
|
||||
# its own VPS once traffic justifies it.
|
||||
echo "[status] Deploying to web-eu-central (co-located)..."
|
||||
$SSH $WEB_HOST bash << 'REMOTE'
|
||||
cd /opt/pingql
|
||||
git pull
|
||||
cd apps/status
|
||||
/root/.bun/bin/bun install
|
||||
systemctl restart pingql-status
|
||||
systemctl restart caddy
|
||||
echo "Status deployed and restarted"
|
||||
REMOTE
|
||||
}
|
||||
|
||||
deploy_monitor() {
|
||||
echo "[monitor] Deploying to all 4 monitors in parallel..."
|
||||
for host in "${MONITOR_HOSTS[@]}"; do
|
||||
|
|
@ -97,8 +114,8 @@ REMOTE
|
|||
|
||||
# Parse args — supports both "./deploy.sh web api" and "./deploy.sh web,api"
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 [web|api|pay|monitor|db|all] [...]"
|
||||
echo " $0 web,api,pay (comma-separated)"
|
||||
echo "Usage: $0 [web|api|pay|status|monitor|db|all] [...]"
|
||||
echo " $0 web,api,status (comma-separated)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -120,11 +137,12 @@ deploy_target() {
|
|||
api) deploy_api ;;
|
||||
pay) deploy_pay ;;
|
||||
web) deploy_web ;;
|
||||
status) deploy_status ;;
|
||||
monitor) deploy_monitor ;;
|
||||
nuke-db) nuke_db ;;
|
||||
sync) sync_time ;;
|
||||
all) deploy_db; deploy_api; deploy_pay; deploy_web; deploy_monitor ;;
|
||||
*) echo "Unknown target: $1 (valid: web, api, pay, monitor, db, nuke-db, sync, all)"; exit 1 ;;
|
||||
all) deploy_db; deploy_api; deploy_pay; deploy_web; deploy_status; deploy_monitor ;;
|
||||
*) echo "Unknown target: $1 (valid: web, api, pay, status, monitor, db, nuke-db, sync, all)"; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue