refactor tier 3

This commit is contained in:
2026-04-08 15:26:17 +04:00
parent c74ee9856e
commit 5bf02b47d5
30 changed files with 2263 additions and 58 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@pingql/status",
"version": "0.1.0",
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"elysia": "^1.4.27",
"eta": "^4.5.1",
"postgres": "^3.4.8"
},
"devDependencies": {
"@types/bun": "^1.3.10",
"typescript": "^5.9.3"
}
}
+47
View File
@@ -0,0 +1,47 @@
// Password gate for protected status pages. We sign a short-lived cookie with
// the page id + a secret so a successful password unlock survives across page
// loads without us having to hit Postgres on every request.
import { createHmac, timingSafeEqual } from "crypto";
const SECRET = process.env.STATUS_COOKIE_SECRET ?? process.env.MONITOR_TOKEN ?? "dev-secret-change-me";
const COOKIE = "pingql_status_auth";
const TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
function sign(payload: string): string {
return createHmac("sha256", SECRET).update(payload).digest("hex");
}
export function makeAuthCookie(pageId: string): string {
const exp = Date.now() + TTL_MS;
const payload = `${pageId}.${exp}`;
const sig = sign(payload);
const value = `${payload}.${sig}`;
return `${COOKIE}=${value}; Path=/; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax${process.env.NODE_ENV !== "development" ? "; Secure" : ""}`;
}
export function verifyAuthCookie(cookieHeader: string | null | undefined, pageId: string): boolean {
if (!cookieHeader) return false;
const match = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`));
if (!match) return false;
const value = match.slice(COOKIE.length + 1);
const lastDot = value.lastIndexOf(".");
if (lastDot < 0) return false;
const payload = value.slice(0, lastDot);
const sig = value.slice(lastDot + 1);
const [id, expStr] = payload.split(".");
if (id !== pageId) return false;
const exp = Number(expStr);
if (!Number.isFinite(exp) || Date.now() > exp) return false;
const expected = sign(payload);
if (expected.length !== sig.length) return false;
try {
return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
} catch {
return false;
}
}
export async function checkPassword(plain: string, hash: string): Promise<boolean> {
return await Bun.password.verify(plain, hash);
}
+38
View File
@@ -0,0 +1,38 @@
// Tiny in-memory TTL cache keyed by string. Status pages serve the same payload
// to many visitors during an outage; we don't want every page hit to fan out to
// Postgres. The cache is per-process; behind a load balancer each replica fills
// independently, which is fine — short TTLs converge quickly.
interface Entry<T> { value: T; expires: number }
const store = new Map<string, Entry<unknown>>();
export function cacheGet<T>(key: string): T | null {
const entry = store.get(key) as Entry<T> | undefined;
if (!entry) return null;
if (Date.now() > entry.expires) {
store.delete(key);
return null;
}
return entry.value;
}
export function cacheSet<T>(key: string, value: T, ttlSeconds: number): void {
// Soft cap so a runaway path can't blow memory. LRU-ish: oldest entries get
// dropped first by insertion order (Map preserves it).
if (store.size > 5000) {
const firstKey = store.keys().next().value;
if (firstKey) store.delete(firstKey);
}
store.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
}
// Convenience wrapper: get-or-fill. The producer runs at most once per key
// during the TTL window across this process.
export async function cached<T>(key: string, ttlSeconds: number, producer: () => Promise<T>): Promise<T> {
const hit = cacheGet<T>(key);
if (hit !== null) return hit;
const value = await producer();
cacheSet(key, value, ttlSeconds);
return value;
}
+260
View File
@@ -0,0 +1,260 @@
// Loads the read-only data needed to render a public status page. NEVER reads
// the raw `pings` table — uses `monitor_region_state` for current state and
// `monitor_uptime_rollup` for historical uptime windows.
import sql from "./db";
export type Window = "24h" | "7d" | "30d" | "90d";
export type BucketType = "hourly" | "daily" | "weekly";
const WINDOW_TO_BUCKET: Record<Window, { bucket: BucketType; count: number }> = {
"24h": { bucket: "hourly", count: 24 },
"7d": { bucket: "daily", count: 7 },
"30d": { bucket: "daily", count: 30 },
"90d": { bucket: "weekly", count: 13 },
};
export interface StatusPageRow {
id: string;
account_id: string;
slug: string;
title: string;
description: string | null;
theme: "auto" | "light" | "dark";
password_hash: string | null;
index_search: boolean;
show_powered_by: boolean;
show_response_time:boolean;
show_cert_expiry: boolean;
default_window: Window;
custom_css: string | null;
footer_text: string | null;
og_image_url: string | null;
analytics_html: string | null;
auto_refresh_s: number;
}
export interface MonitorRow {
id: string;
display_name: string;
url: string;
group_id: string | null;
position: number;
current_state: "up" | "down" | "unknown";
region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>;
uptime_pct: number | null; // for the page's default_window
buckets: Array<{ start: string; total: number; up: number }>; // bar chart input
avg_latency: number | null;
latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>;
}
export interface GroupRow {
id: string;
name: string;
position: number;
}
export interface IncidentSummary {
id: string;
title: string;
status: string;
severity: string;
pinned: boolean;
started_at: string;
resolved_at: string | null;
latest_update_html: string | null;
}
export async function loadStatusPage(slug: string): Promise<StatusPageRow | null> {
const [row] = await sql<StatusPageRow[]>`SELECT * FROM status_pages WHERE slug = ${slug}`;
return row ?? null;
}
export async function loadGroups(pageId: string): Promise<GroupRow[]> {
return sql<GroupRow[]>`
SELECT id, name, position FROM status_page_groups
WHERE status_page_id = ${pageId}
ORDER BY position ASC, name ASC
`;
}
export async function loadMonitors(pageId: string, window: Window): Promise<MonitorRow[]> {
// Step 1: page → monitors with display overrides + group + position.
const monitorRows = await sql<any[]>`
SELECT
spm.monitor_id AS id,
COALESCE(spm.display_name, m.name) AS display_name,
m.url,
spm.group_id,
spm.position
FROM status_page_monitors spm
JOIN monitors m ON m.id = spm.monitor_id
WHERE spm.status_page_id = ${pageId}
ORDER BY spm.position ASC, m.name ASC
`;
if (monitorRows.length === 0) return [];
const ids = monitorRows.map((r) => r.id);
// Step 2: per-region current state for these monitors.
const stateRows = await sql<{ monitor_id: string; region: string; last_state: string | null; updated_at: string }[]>`
SELECT monitor_id, region, last_state, updated_at
FROM monitor_region_state
WHERE monitor_id = ANY(${sql.array(ids)}::text[])
`;
const stateByMonitor: Record<string, MonitorRow["region_states"]> = {};
for (const s of stateRows) {
if (!stateByMonitor[s.monitor_id]) stateByMonitor[s.monitor_id] = [];
stateByMonitor[s.monitor_id]!.push({
region: s.region,
state: (s.last_state as any) ?? "unknown",
updated_at: s.updated_at,
});
}
// Step 3: uptime rollup buckets covering the requested window.
const { bucket, count } = WINDOW_TO_BUCKET[window];
const truncUnit = bucket === "hourly" ? "hour" : bucket === "daily" ? "day" : "week";
const intervalLiteral = `${count} ${truncUnit}s`;
const rollupRows = await sql<any[]>`
SELECT monitor_id, bucket_start, sum(total)::int AS total, sum(up_count)::int AS up_count, avg(avg_latency)::real AS avg_latency
FROM monitor_uptime_rollup
WHERE monitor_id = ANY(${sql.array(ids)}::text[])
AND bucket_type = ${bucket}
AND bucket_start > date_trunc(${truncUnit}, now()) - ${intervalLiteral}::interval
GROUP BY monitor_id, bucket_start
ORDER BY monitor_id, bucket_start ASC
`;
const bucketsByMonitor: Record<string, MonitorRow["buckets"]> = {};
const latencyByMonitor: Record<string, { sum: number; n: number }> = {};
for (const r of rollupRows) {
if (!bucketsByMonitor[r.monitor_id]) bucketsByMonitor[r.monitor_id] = [];
bucketsByMonitor[r.monitor_id]!.push({
start: r.bucket_start instanceof Date ? r.bucket_start.toISOString() : String(r.bucket_start),
total: r.total,
up: r.up_count,
});
if (r.avg_latency != null) {
const acc = latencyByMonitor[r.monitor_id] ?? { sum: 0, n: 0 };
acc.sum += r.avg_latency * r.total;
acc.n += r.total;
latencyByMonitor[r.monitor_id] = acc;
}
}
// Step 4: tiny recent latency history for the sparkline (last 30 hourly buckets).
const latRows = await sql<any[]>`
SELECT monitor_id, region, bucket_start, avg_latency
FROM monitor_uptime_rollup
WHERE monitor_id = ANY(${sql.array(ids)}::text[])
AND bucket_type = 'hourly'
AND bucket_start > now() - interval '30 hours'
ORDER BY monitor_id, bucket_start ASC
`;
const latencyByMonitorList: Record<string, MonitorRow["latency_history"]> = {};
for (const r of latRows) {
if (!latencyByMonitorList[r.monitor_id]) latencyByMonitorList[r.monitor_id] = [];
latencyByMonitorList[r.monitor_id]!.push({
region: r.region,
latency_ms: r.avg_latency != null ? Math.round(r.avg_latency) : null,
ts: r.bucket_start instanceof Date ? r.bucket_start.toISOString() : String(r.bucket_start),
});
}
return monitorRows.map((m) => {
const region_states = stateByMonitor[m.id] ?? [];
let current_state: MonitorRow["current_state"] = "unknown";
if (region_states.length > 0) {
const anyDown = region_states.some((s) => s.state === "down");
const anyUp = region_states.some((s) => s.state === "up");
current_state = anyDown ? "down" : anyUp ? "up" : "unknown";
}
const buckets = bucketsByMonitor[m.id] ?? [];
let uptime_pct: number | null = null;
if (buckets.length > 0) {
const tot = buckets.reduce((a, b) => a + b.total, 0);
const upT = buckets.reduce((a, b) => a + b.up, 0);
uptime_pct = tot > 0 ? +(100 * upT / tot).toFixed(2) : null;
}
const latAcc = latencyByMonitor[m.id];
const avg_latency = latAcc && latAcc.n > 0 ? Math.round(latAcc.sum / latAcc.n) : null;
return {
id: m.id,
display_name: m.display_name,
url: m.url,
group_id: m.group_id,
position: m.position,
current_state,
region_states,
uptime_pct,
buckets,
avg_latency,
latency_history: latencyByMonitorList[m.id] ?? [],
} as MonitorRow;
});
}
export async function loadIncidents(pageId: string): Promise<{ active: IncidentSummary[]; recent: IncidentSummary[] }> {
const incidents = await sql<any[]>`
SELECT i.*
FROM incidents i
JOIN incident_status_pages isp ON isp.incident_id = i.id
WHERE isp.status_page_id = ${pageId}
ORDER BY i.started_at DESC
LIMIT 50
`;
if (incidents.length === 0) return { active: [], recent: [] };
const ids = incidents.map((i) => i.id);
// Latest update html per incident.
const latestUpdates = await sql<any[]>`
SELECT DISTINCT ON (incident_id) incident_id, body_html, status, created_at
FROM incident_updates
WHERE incident_id = ANY(${sql.array(ids)}::uuid[])
ORDER BY incident_id, created_at DESC
`;
const latestByIncident: Record<string, string> = {};
for (const u of latestUpdates) latestByIncident[u.incident_id] = u.body_html;
const enriched: IncidentSummary[] = incidents.map((i) => ({
id: i.id,
title: i.title,
status: i.status,
severity: i.severity,
pinned: i.pinned,
started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at),
resolved_at: i.resolved_at ? (i.resolved_at instanceof Date ? i.resolved_at.toISOString() : String(i.resolved_at)) : null,
latest_update_html: latestByIncident[i.id] ?? null,
}));
const active = enriched.filter((i) => i.pinned && !i.resolved_at);
const recent = enriched.filter((i) => !active.includes(i));
return { active, recent };
}
export interface PagePayload {
page: Omit<StatusPageRow, "password_hash"> & { has_password: boolean };
groups: GroupRow[];
monitors: MonitorRow[];
incidents: { active: IncidentSummary[]; recent: IncidentSummary[] };
generated_at: string;
}
export async function loadPagePayload(slug: string, window?: Window): Promise<PagePayload | null> {
const page = await loadStatusPage(slug);
if (!page) return null;
const win = (window ?? page.default_window) as Window;
const [groups, monitors, incidents] = await Promise.all([
loadGroups(page.id),
loadMonitors(page.id, win),
loadIncidents(page.id),
]);
const { password_hash, ...publicPage } = page;
return {
page: { ...publicPage, has_password: !!password_hash },
groups,
monitors,
incidents,
generated_at: new Date().toISOString(),
};
}
+11
View File
@@ -0,0 +1,11 @@
// Read-only Postgres client. The status service does NOT run migrations —
// schema is owned by apps/api. This file just opens a connection.
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", {
max: 10,
idle_timeout: 30,
connect_timeout: 10,
});
export default sql;
+172
View File
@@ -0,0 +1,172 @@
import { Elysia } from "elysia";
import { Eta } from "eta";
import { resolve } from "path";
import sql from "./db";
import { loadStatusPage, loadPagePayload, type Window } from "./data";
import { renderRss } from "./render/rss";
import { renderBadge, badgeFromState } from "./render/badge";
import { cached } from "./cache";
import { allow } from "./rate-limit";
import { checkPassword, makeAuthCookie, verifyAuthCookie } from "./auth";
// Crash isolation: log loudly, never exit. Status pages going down silently is
// worse than weird logs.
process.on("unhandledRejection", (reason) => console.error("[unhandledRejection]", reason));
process.on("uncaughtException", (err) => console.error("[uncaughtException]", err));
const eta = new Eta({ views: resolve(import.meta.dir, "./views"), cache: true, defaultExtension: ".ejs" });
const PUBLIC_BASE = process.env.STATUS_BASE_URL ?? "https://status.pingql.com";
function clientIp(req: Request): string {
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| req.headers.get("cf-connecting-ip")
|| "unknown";
}
function notFound(): Response {
return new Response(eta.render("not-found", {}), {
status: 404,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
function rateLimited(): Response {
return new Response("Too many requests", { status: 429 });
}
function isAuthorised(page: { id: string; password_hash: string | null }, req: Request): boolean {
if (!page.password_hash) return true;
return verifyAuthCookie(req.headers.get("cookie"), page.id);
}
const app = new Elysia()
.get("/", () => new Response("PingQL status service", {
headers: { "content-type": "text/plain" },
}))
// Public HTML page
.get("/:slug", async ({ params, request, set }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited();
const page = await cached(`page:${params.slug}`, 60, () => loadStatusPage(params.slug));
if (!page) return notFound();
if (!isAuthorised(page, request)) {
return new Response(eta.render("password", { title: page.title, slug: page.slug, error: null }), {
status: 401,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
const payload = await cached(`payload:${params.slug}`, 60, () => loadPagePayload(params.slug));
if (!payload) return notFound();
const html = eta.render("page", payload);
const headers: Record<string, string> = {
"content-type": "text/html; charset=utf-8",
"cache-control": "public, max-age=30, s-maxage=60",
"x-frame-options":"SAMEORIGIN",
"x-content-type-options": "nosniff",
"referrer-policy":"strict-origin-when-cross-origin",
};
if (!page.index_search) headers["x-robots-tag"] = "noindex, nofollow";
return new Response(html, { headers });
})
// Public JSON
.get("/:slug.json", async ({ params, request, set, query }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited();
const page = await cached(`page:${params.slug}`, 60, () => loadStatusPage(params.slug));
if (!page) { set.status = 404; return { error: "not found" }; }
if (!isAuthorised(page, request)) { set.status = 401; return { error: "password required" }; }
const win = (query as any)?.window as Window | undefined;
const cacheKey = `payload:${params.slug}:${win ?? page.default_window}`;
const payload = await cached(cacheKey, 60, () => loadPagePayload(params.slug, win));
if (!payload) { set.status = 404; return { error: "not found" }; }
return new Response(JSON.stringify(payload), {
headers: {
"content-type": "application/json",
"cache-control": "public, max-age=30, s-maxage=60",
...(page.index_search ? {} : { "x-robots-tag": "noindex, nofollow" }),
},
});
})
// Public RSS
.get("/:slug.rss", async ({ params, request }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited();
const page = await loadStatusPage(params.slug);
if (!page) return notFound();
const xml = await cached(`rss:${params.slug}`, 300, () => renderRss(page, PUBLIC_BASE));
return new Response(xml, {
headers: {
"content-type": "application/rss+xml; charset=utf-8",
"cache-control": "public, max-age=300, s-maxage=300",
},
});
})
// Public SVG badge
.get("/:slug/badge.svg", async ({ params, request }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited();
const payload = await cached(`payload:${params.slug}`, 60, () => loadPagePayload(params.slug));
if (!payload) return notFound();
const { message, color } = badgeFromState(payload.monitors);
const svg = renderBadge("status", message, color);
return new Response(svg, {
headers: {
"content-type": "image/svg+xml",
"cache-control": "public, max-age=60, s-maxage=60",
},
});
})
// PWA manifest
.get("/:slug/manifest.json", async ({ params }) => {
const page = await loadStatusPage(params.slug);
if (!page) return notFound();
return new Response(JSON.stringify({
name: page.title,
short_name: page.title.slice(0, 12),
description: page.description ?? "",
start_url: `/${page.slug}`,
display: "standalone",
background_color: page.theme === "light" ? "#ffffff" : "#0a0a0a",
theme_color: page.theme === "light" ? "#0ea5e9" : "#0a0a0a",
}), {
headers: {
"content-type": "application/manifest+json",
"cache-control": "public, max-age=86400, s-maxage=86400",
},
});
})
// Password gate POST
.post("/:slug/auth", async ({ params, request }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited();
const page = await loadStatusPage(params.slug);
if (!page) return notFound();
if (!page.password_hash) {
return Response.redirect(`/${page.slug}`, 303);
}
const form = await request.formData();
const password = String(form.get("password") ?? "");
const ok = await checkPassword(password, page.password_hash);
if (!ok) {
return new Response(eta.render("password", { title: page.title, slug: page.slug, error: "Wrong password" }), {
status: 401,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
return new Response(null, {
status: 303,
headers: { "location": `/${page.slug}`, "set-cookie": makeAuthCookie(page.id) },
});
});
const port = Number(process.env.STATUS_PORT ?? 3003);
const server = Bun.serve({
port,
fetch(req) { return app.handle(req); },
});
console.log(`PingQL status service running at http://localhost:${server.port}`);
+31
View File
@@ -0,0 +1,31 @@
// Per-(slug, IP) token bucket. 30 requests in a 10s window. Cheap, in-memory,
// resets on process restart. Behind a load balancer each replica enforces its
// own bucket — that's fine, the goal is "stop a hostile script from melting one
// box", not perfect distributed accounting.
interface Bucket { tokens: number; refillAt: number }
const buckets = new Map<string, Bucket>();
const CAPACITY = 30;
const WINDOW_MS = 10_000;
export function allow(slug: string, ip: string): boolean {
const key = `${slug}\x00${ip}`;
const now = Date.now();
let b = buckets.get(key);
if (!b || now > b.refillAt) {
b = { tokens: CAPACITY, refillAt: now + WINDOW_MS };
buckets.set(key, b);
}
if (b.tokens <= 0) return false;
b.tokens--;
return true;
}
// Periodic sweep so the map doesn't grow forever.
setInterval(() => {
const now = Date.now();
for (const [k, b] of buckets) {
if (now > b.refillAt + WINDOW_MS) buckets.delete(k);
}
}, 60_000).unref?.();
+33
View File
@@ -0,0 +1,33 @@
// Shields-style SVG badge for embedding on README files etc.
export function renderBadge(label: string, message: string, color: string): string {
// Approximate text width: 6.5px per char + 10px padding each side.
const labelW = 10 + label.length * 6.5 + 10;
const messageW = 10 + message.length * 6.5 + 10;
const total = labelW + messageW;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${total.toFixed(0)}" height="20" role="img" aria-label="${label}: ${message}">
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="${total.toFixed(0)}" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="${labelW.toFixed(0)}" height="20" fill="#555"/>
<rect x="${labelW.toFixed(0)}" width="${messageW.toFixed(0)}" height="20" fill="${color}"/>
<rect width="${total.toFixed(0)}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="${(labelW / 2).toFixed(1)}" y="14">${escapeXml(label)}</text>
<text x="${(labelW + messageW / 2).toFixed(1)}" y="14">${escapeXml(message)}</text>
</g>
</svg>`;
}
export function badgeFromState(monitors: Array<{ current_state: "up" | "down" | "unknown" }>): { message: string; color: string } {
if (monitors.length === 0) return { message: "no data", color: "#9f9f9f" };
const down = monitors.filter((m) => m.current_state === "down").length;
if (down === 0) return { message: "operational", color: "#4c1" };
if (down < monitors.length) return { message: "degraded", color: "#dfb317" };
return { message: "down", color: "#e05d44" };
}
function escapeXml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
+62
View File
@@ -0,0 +1,62 @@
// RSS 2.0 feed of incidents on a status page. Unlike Uptime Kuma we include all
// incident lifecycle events (investigating / identified / monitoring / resolved),
// not just initial outages, so subscribers see the full timeline.
import sql from "../db";
import type { StatusPageRow } from "../data";
interface FeedItem {
guid: string;
title: string;
link: string;
pubDate: string;
body_html: string;
}
export async function renderRss(page: StatusPageRow, baseUrl: string): Promise<string> {
const updates = await sql<any[]>`
SELECT iu.id, iu.status, iu.body_html, iu.created_at, i.title AS incident_title, i.id AS incident_id
FROM incident_updates iu
JOIN incidents i ON i.id = iu.incident_id
JOIN incident_status_pages isp ON isp.incident_id = i.id
WHERE isp.status_page_id = ${page.id}
ORDER BY iu.created_at DESC
LIMIT 50
`;
const items: FeedItem[] = updates.map((u) => ({
guid: `update-${u.id}`,
title: `[${u.status}] ${u.incident_title}`,
link: `${baseUrl}/${page.slug}#incident-${u.incident_id}`,
pubDate: new Date(u.created_at).toUTCString(),
body_html: u.body_html,
}));
const channelTitle = escapeXml(`${page.title} — Incidents`);
const channelDescription = escapeXml(page.description || `${page.title} status updates`);
const channelLink = `${baseUrl}/${page.slug}`;
const itemsXml = items.map((it) => `
<item>
<guid isPermaLink="false">${it.guid}</guid>
<title>${escapeXml(it.title)}</title>
<link>${escapeXml(it.link)}</link>
<pubDate>${it.pubDate}</pubDate>
<description><![CDATA[${it.body_html}]]></description>
</item>`).join("");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${channelTitle}</title>
<link>${escapeXml(channelLink)}</link>
<description>${channelDescription}</description>
<ttl>300</ttl>
${itemsXml}
</channel>
</rss>`;
}
function escapeXml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
+4
View File
@@ -0,0 +1,4 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>Not found</title>
<style>body { background:#0a0a0a; color:#94a3b8; font-family:-apple-system, sans-serif; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; } .card{text-align:center} h1{color:#f1f5f9; margin:0 0 0.5rem; font-size:1.5rem} a{color:#38bdf8;text-decoration:none}</style>
</head><body><div class="card"><h1>Status page not found</h1><p>The page you're looking for doesn't exist.</p><p><a href="https://pingql.com">PingQL</a></p></div></body></html>
+223
View File
@@ -0,0 +1,223 @@
<%
const page = it.page;
const monitors = it.monitors;
const groups = it.groups;
const incidents = it.incidents;
const themeClass = page.theme === 'dark' ? 'dark' : page.theme === 'light' ? 'light' : '';
// Group monitors. group_id null = "ungrouped".
const grouped = {};
for (const m of monitors) {
const key = m.group_id || '';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(m);
}
const groupOrder = [...groups.map(g => g.id), ''];
function fmtPct(p) {
if (p == null) return '—';
return p === 100 ? '100%' : p.toFixed(2) + '%';
}
function statusLabel(s) {
if (s === 'up') return 'Operational';
if (s === 'down') return 'Down';
return 'Unknown';
}
function statusColor(s) {
if (s === 'up') return '#10b981';
if (s === 'down') return '#ef4444';
return '#9ca3af';
}
function bucketColor(b) {
if (b.total === 0) return '#374151';
if (b.up === b.total) return '#10b981';
if (b.up === 0) return '#ef4444';
return '#f59e0b';
}
// Overall status: down if any monitor is down, degraded if any partial, else up.
let overall = 'up';
for (const m of monitors) {
const partial = m.region_states.some(r => r.state === 'down') && m.region_states.some(r => r.state === 'up');
if (m.current_state === 'down') { overall = 'down'; break; }
if (partial && overall !== 'down') overall = 'degraded';
}
const overallText = overall === 'up' ? 'All systems operational'
: overall === 'degraded' ? 'Some systems degraded'
: 'Major outage in progress';
const overallColor = overall === 'up' ? '#10b981'
: overall === 'degraded' ? '#f59e0b'
: '#ef4444';
%><!DOCTYPE html>
<html lang="en" class="<%= themeClass %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= page.title %></title>
<% if (page.description) { %><meta name="description" content="<%= page.description %>"><% } %>
<% if (!page.index_search) { %><meta name="robots" content="noindex,nofollow"><% } %>
<meta property="og:title" content="<%= page.title %>">
<% if (page.description) { %><meta property="og:description" content="<%= page.description %>"><% } %>
<% if (page.og_image_url) { %><meta property="og:image" content="<%= page.og_image_url %>"><% } %>
<link rel="alternate" type="application/rss+xml" title="<%= page.title %> incidents" href="/<%= page.slug %>.rss">
<link rel="manifest" href="/<%= page.slug %>/manifest.json">
<style>
:root {
--bg: #ffffff; --fg: #0f172a; --muted: #64748b; --card: #f8fafc;
--border: #e2e8f0; --accent: #0ea5e9; --green: #10b981; --red: #ef4444; --amber: #f59e0b;
}
html.dark, html:not(.light):not(.dark) { color-scheme: dark; }
@media (prefers-color-scheme: dark) {
html:not(.light) {
--bg: #0a0a0a; --fg: #f1f5f9; --muted: #94a3b8; --card: #111827;
--border: #1f2937; --accent: #38bdf8;
}
}
html.dark {
--bg: #0a0a0a; --fg: #f1f5f9; --muted: #94a3b8; --card: #111827;
--border: #1f2937; --accent: #38bdf8;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; line-height: 1.5; }
main { max-width: 880px; margin: 0 auto; padding: 3rem 1.5rem; }
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; }
.muted { color: var(--muted); font-size: 0.875rem; }
.overall { padding: 1.25rem 1.5rem; border-radius: 12px; color: white; font-weight: 600; font-size: 1.05rem; margin: 1.5rem 0 2rem; display: flex; align-items: center; gap: 0.75rem; }
.overall .dot { width: 12px; height: 12px; border-radius: 50%; background: white; }
.group-title { font-size: 0.85rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin: 2rem 0 0.75rem; }
.monitors { display: flex; flex-direction: column; gap: 0.5rem; }
.monitor { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 1rem 1.25rem; }
.monitor-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
.monitor-name { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
.monitor-name .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.monitor-name .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.monitor-meta { display: flex; gap: 1rem; align-items: center; font-size: 0.85rem; color: var(--muted); }
.uptime-pct { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--fg); }
.bars { display: flex; gap: 2px; height: 32px; margin-top: 0.5rem; align-items: stretch; }
.bar { flex: 1; min-width: 0; border-radius: 2px; }
.regions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; font-size: 0.75rem; }
.region { padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--border); }
.region.up { color: var(--green); border-color: rgba(16,185,129,0.3); }
.region.down { color: var(--red); border-color: rgba(239,68,68,0.3); }
.incidents { margin-bottom: 2rem; }
.incident { background: var(--card); border-left: 4px solid var(--amber); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
.incident.critical { border-left-color: var(--red); }
.incident.major { border-left-color: var(--amber); }
.incident-title { font-weight: 600; margin-bottom: 0.25rem; }
.incident-meta { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.5rem; }
.incident-body p { margin: 0.5rem 0; }
.incident-body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.past-incidents { margin-top: 3rem; }
.past-incidents h2 { font-size: 1.1rem; margin-bottom: 1rem; }
.past { padding: 0.75rem 0; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; gap: 1rem; }
.past-title { font-weight: 500; }
.past-meta { color: var(--muted); font-size: 0.8rem; }
footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
<% if (page.custom_css) { %><%~ page.custom_css %><% } %>
</style>
<% if (page.analytics_html) { %><%~ page.analytics_html %><% } %>
</head>
<body>
<main>
<h1><%= page.title %></h1>
<% if (page.description) { %><div class="muted"><%= page.description %></div><% } %>
<div class="overall" style="background: <%= overallColor %>;">
<span class="dot"></span>
<span><%= overallText %></span>
</div>
<% if (incidents.active.length > 0) { %>
<div class="incidents">
<% incidents.active.forEach(function(i) { %>
<div id="incident-<%= i.id %>" class="incident <%= i.severity %>">
<div class="incident-title"><%= i.title %></div>
<div class="incident-meta"><%= i.status %> · started <%= new Date(i.started_at).toLocaleString() %></div>
<% if (i.latest_update_html) { %><div class="incident-body"><%~ i.latest_update_html %></div><% } %>
</div>
<% }); %>
</div>
<% } %>
<% groupOrder.forEach(function(gid) {
const list = grouped[gid];
if (!list || list.length === 0) return;
const groupName = gid ? (groups.find(g => g.id === gid)?.name || '') : '';
%>
<% if (groupName) { %><div class="group-title"><%= groupName %></div><% } %>
<div class="monitors">
<% list.forEach(function(m) { %>
<div class="monitor">
<div class="monitor-head">
<div class="monitor-name">
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
<span class="name"><%= m.display_name %></span>
</div>
<div class="monitor-meta">
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
<span class="uptime-pct"><%= fmtPct(m.uptime_pct) %></span>
</div>
</div>
<% if (m.buckets && m.buckets.length > 0) { %>
<div class="bars" title="<%= statusLabel(m.current_state) %>">
<% m.buckets.forEach(function(b) { %>
<div class="bar" style="background: <%= bucketColor(b) %>;"></div>
<% }); %>
</div>
<% } %>
<% if (m.region_states && m.region_states.length > 1) { %>
<div class="regions">
<% m.region_states.forEach(function(r) { %>
<span class="region <%= r.state %>"><%= r.region %></span>
<% }); %>
</div>
<% } %>
</div>
<% }); %>
</div>
<% }); %>
<% if (incidents.recent.length > 0) { %>
<div class="past-incidents">
<h2>Past incidents</h2>
<% incidents.recent.forEach(function(i) { %>
<div class="past">
<div>
<div class="past-title"><%= i.title %></div>
<div class="past-meta"><%= i.status %> · <%= new Date(i.started_at).toLocaleDateString() %></div>
</div>
</div>
<% }); %>
</div>
<% } %>
<footer>
<% if (page.footer_text) { %><div><%= page.footer_text %></div><% } %>
<% if (page.show_powered_by) { %><div>Status powered by <a href="https://pingql.com">PingQL</a></div><% } %>
</footer>
</main>
<% if (page.auto_refresh_s > 0) { %>
<script>
// Auto-refresh data without a full page reload. Polls /<slug>.json and
// patches just the bar/uptime/dot DOM nodes.
(function() {
const slug = <%~ JSON.stringify(page.slug) %>;
const intervalMs = Math.max(10, <%= page.auto_refresh_s %>) * 1000;
async function refresh() {
try {
const r = await fetch('/' + slug + '.json', { cache: 'no-store' });
if (!r.ok) return;
// Reload on next idle for simplicity. The JSON payload is already cached
// server-side; the visible diff is small.
location.reload();
} catch {}
}
setTimeout(refresh, intervalMs);
})();
</script>
<% } %>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title><%= it.title %> — Password required</title>
<style>
body { background: #0a0a0a; color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
.card { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; }
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
p { color: #94a3b8; font-size: 0.875rem; margin: 0 0 1.5rem; }
input { width: 100%; padding: 0.75rem 1rem; background: #0a0a0a; border: 1px solid #374151; border-radius: 8px; color: #f1f5f9; font-size: 1rem; box-sizing: border-box; }
input:focus { outline: none; border-color: #38bdf8; }
button { width: 100%; margin-top: 1rem; padding: 0.75rem; background: #0ea5e9; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 1rem; }
button:hover { background: #0284c7; }
.err { color: #ef4444; font-size: 0.85rem; margin-top: 0.75rem; }
</style>
</head>
<body>
<div class="card">
<h1><%= it.title %></h1>
<p>This status page is password protected.</p>
<form method="POST" action="/<%= it.slug %>/auth">
<input type="password" name="password" placeholder="Password" autofocus required>
<button type="submit">Unlock</button>
<% if (it.error) { %><div class="err"><%= it.error %></div><% } %>
</form>
</div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ESNext",
"strict": true,
"skipLibCheck": true,
"types": ["bun"],
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src"]
}