// Tangled status page — Cloudflare Worker.
//
// Public: GET / — dashboard (server-rendered)
// GET /status.json — machine-readable status (landing page)
// GET /rss.xml — incident feed (RSS 2.0)
// /static/*, /favicon.svg — assets (fonts bundled in-worker)
// Admin (Cloudflare Access):
// GET /admin — operator UI
// POST /api/services — create service
// POST /api/services/:id/toggle — flip enabled
// DELETE /api/services/:id — delete service (cascades checks)
// POST /api/incidents — create incident
// POST /api/incidents/:id/updates — append update (optional state change)
// POST /api/incidents/:id/resolve — resolve incident
// Cron: scheduled() every minute — run due checks, retain 90d
// Compiled from the repo's shared `input.css` + `tailwind.config.js` by
// `build:css`; inlined per page (mirrors docs/template.html) via wrangler's
// Text bundling rule, so no generated-CSS asset is deployed.
import twCss from '../static/tw.css';
import { VerifiedAccessIdentity } from './access';
import { LOGOTYPE_SVG } from './logotype';
import { serveFont } from './fonts';
import { FOOTER_HTML } from './footer';
export interface Env {
DB: D1Database;
ASSETS: Fetcher;
/** 'production' when deployed (see wrangler.jsonc vars); only
* 'development' skips the Access identity check for local `wrangler dev`. */
ENVIRONMENT: string;
ACCESS_TEAM_DOMAIN: string;
ACCESS_AUD: string;
}
const STATES = ['investigating', 'identified', 'monitoring', 'resolved'] as const;
type IncidentState = (typeof STATES)[number];
const METHODS = ['GET', 'HEAD', 'POST'];
const SITE_URL = 'https://status.tangled.org';
// Uptime-history bars: the last 24 hours in hourly buckets, colored
// green/yellow/red per bucket (status-page style).
const BAR_BUCKETS = 24;
interface ServiceBucket {
up: number;
total: number;
}
interface ServiceHistory {
/** bucket keys for the last 24 hours, oldest first ('YYYY-MM-DDTHH:00:00Z') */
hourKeys: string[];
/** 24 hourly buckets aligned with hourKeys; null = no checks recorded */
buckets: (ServiceBucket | null)[];
/** % uptime over the last 30 days, or null when there are no checks */
uptime30: number | null;
}
interface Service {
id: number;
name: string;
url: string;
method: string;
expected_status: number;
interval_seconds: number;
timeout_ms: number;
enabled: number;
created_at: string;
updated_at: string;
}
interface LatestCheck {
checked_at: string;
ok: number;
http_status: number | null;
latency_ms: number | null;
}
interface Incident {
id: number;
title: string;
description: string;
state: IncidentState;
created_at: string;
resolved_at: string | null;
}
interface IncidentUpdate {
id: number;
incident_id: number;
body: string;
state: IncidentState | null;
created_at: string;
}
// ---------------------------------------------------------------------------
// Schema bootstrap (no migrations: a fresh D1 is created here on first use)
// ---------------------------------------------------------------------------
const SCHEMA_STATEMENTS = [
`CREATE TABLE IF NOT EXISTS services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
expected_status INTEGER NOT NULL DEFAULT 200,
interval_seconds INTEGER NOT NULL DEFAULT 60,
timeout_ms INTEGER NOT NULL DEFAULT 5000,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)`,
`CREATE TABLE IF NOT EXISTS checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
ok INTEGER NOT NULL,
http_status INTEGER,
latency_ms INTEGER,
error TEXT,
checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)`,
`CREATE INDEX IF NOT EXISTS checks_service_time ON checks(service_id, checked_at)`,
`CREATE TABLE IF NOT EXISTS incidents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'investigating',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
resolved_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS incident_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
body TEXT NOT NULL,
state TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)`,
`CREATE INDEX IF NOT EXISTS incidents_created_time ON incidents(created_at)`,
`CREATE INDEX IF NOT EXISTS incident_updates_incident_time ON incident_updates(incident_id, created_at)`,
] as const;
let schemaPromise: Promise<void> | null = null;
// Runs the idempotent schema DDL once per worker instance; every DB-touching
// handler awaits it so a brand-new database self-initializes on first request.
function ensureSchema(env: Env): Promise<void> {
if (!schemaPromise) {
schemaPromise = (async () => {
try {
await env.DB.batch(SCHEMA_STATEMENTS.map((sql) => env.DB.prepare(sql)));
} catch (e) {
schemaPromise = null; // allow retry on the next request
throw e;
}
})();
}
return schemaPromise;
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json; charset=utf-8' },
});
}
function errorJson(message: string, status: number): Response {
return json({ error: message }, status);
}
const ALLOWED_ORIGINS = new Set([
'https://status.tangled.org',
'http://localhost:8787',
'http://127.0.0.1:8787',
]);
// CSRF defense for state-changing /api requests: browser requests carry an
// Origin header; reject any that isn't the status origin (or local dev).
// Clients without an Origin header (curl, scripts) are unaffected.
function isSameOrigin(request: Request): boolean {
const origin = request.headers.get('origin');
if (origin === null) return true;
return ALLOWED_ORIGINS.has(origin);
}
// Parses a JSON body into a plain object; returns null for empty, malformed,
// array, or primitive bodies so handlers can answer 400 instead of throwing.
async function readJsonObject(request: Request): Promise<Record<string, unknown> | null> {
let raw: unknown;
try {
raw = await request.json();
} catch {
return null;
}
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
return raw as Record<string, unknown>;
}
function stripControlChars(s: string): string {
return s.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
}
function html(body: string, status = 200): Response {
return new Response(body, {
status,
headers: { 'content-type': 'text/html; charset=utf-8' },
});
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function fmt(s: string | null): string {
return s ? s.slice(0, 19).replace('T', ' ') + ' UTC' : '—';
}
function stateBadgeClass(state: IncidentState): string {
switch (state) {
case 'investigating':
return 'bg-red-600';
case 'identified':
return 'bg-yellow-500';
case 'monitoring':
return 'bg-blue-600';
case 'resolved':
return 'bg-green-600';
}
}
function stateLabel(state: IncidentState): string {
return state[0].toUpperCase() + state.slice(1);
}
function isState(v: unknown): v is IncidentState {
return typeof v === 'string' && (STATES as readonly string[]).includes(v);
}
function intOr(v: unknown, def: number): number {
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : def;
}
function methodOr(v: unknown): string {
return typeof v === 'string' && METHODS.includes(v.toUpperCase())
? v.toUpperCase()
: 'GET';
}
// ---------------------------------------------------------------------------
// HTML shell / pages
// ---------------------------------------------------------------------------
function page(title: string, body: string, autoRefresh: boolean): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>if (!document.documentElement.dataset.theme && matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.dataset.theme = 'dark';</script>
<title>${escapeHtml(title)}</title>
<style>
${twCss}
</style>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
${autoRefresh ? '<script>setTimeout(() => location.reload(), 30000)</script>\n' : ''}</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans min-h-svh flex flex-col">
<div class="container flex-1 w-full">
${body}
</div>
${FOOTER_HTML}
</body>
</html>`;
}
function headerRow(pillClass: string, pillLabel: string): string {
// Full Tangled logotype at top-left (inlined, adapts via currentColor);
// below it, the Svelte-app settings header (typography-heading-2 semantics:
// 30px / 600 / 30px) + the overall status pill.
return `<header class="pt-8 pb-6">
<div class="flex items-center">
<a href="/" class="no-underline hover:no-underline">${LOGOTYPE_SVG}</a>
</div>
<div class="flex min-h-9 w-full items-center justify-between gap-x-4 gap-y-2 pt-6 pb-6 flex-wrap">
<h1 style="font-size:30px;line-height:30px;font-weight:600" class="text-gray-900 dark:text-white">Service Status</h1>
<span class="inline-flex items-center ${pillClass} text-white text-sm rounded px-3 py-1 shrink-0">${escapeHtml(pillLabel)}</span>
</div>
</header>`;
}
async function serviceHistory(env: Env, id: number): Promise<ServiceHistory> {
const now = Date.now();
const hourKeys = Array.from({ length: BAR_BUCKETS }, (_, i) => {
const d = new Date(now - (BAR_BUCKETS - 1 - i) * 3_600_000);
return d.toISOString().slice(0, 13) + ':00:00Z';
});
const rows = (
await env.DB.prepare(
`SELECT strftime('%Y-%m-%dT%H:00:00Z', checked_at) AS bucket,
COUNT(*) AS total, SUM(CASE WHEN ok = 1 THEN 1 ELSE 0 END) AS up
FROM checks
WHERE service_id = ?1 AND checked_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day')
GROUP BY bucket`,
).bind(id).all<{ bucket: string; total: number; up: number }>()
).results;
const byBucket = new Map(rows.map((r) => [r.bucket, r]));
const buckets = hourKeys.map((h) => {
const r = byBucket.get(h);
return r && r.total > 0 ? { up: Number(r.up), total: Number(r.total) } : null;
});
const uptime = await env.DB.prepare(
`SELECT COUNT(*) AS total, SUM(CASE WHEN ok = 1 THEN 1 ELSE 0 END) AS up
FROM checks WHERE service_id = ?1 AND checked_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 days')`,
).bind(id).first<{ total: number; up: number }>();
return {
hourKeys,
buckets,
uptime30:
uptime && uptime.total > 0
? (Number(uptime.up) / Number(uptime.total)) * 100
: null,
};
}
function formatUptime(p: number): string {
// Three decimal places below 100% (e.g. 99.973%); exactly full uptime
// renders as the clean "100%".
const rounded = Math.round(p * 1000) / 1000;
return rounded >= 100 ? '100%' : rounded.toFixed(3) + '%';
}
function barStyle(
b: ServiceBucket | null,
incident: boolean,
): { cls: string; style: string } {
// Discrete states: gray = no checks, green = all up, yellow = mixed,
// red = all failed or an incident covered the hour.
if (incident) return { cls: '', style: 'background-color:#ef4444' };
if (b === null) return { cls: 'bg-gray-200 dark:bg-gray-700', style: '' };
if (b.up === 0) return { cls: '', style: 'background-color:#ef4444' };
if (b.up < b.total) return { cls: '', style: 'background-color:#eab308' };
return { cls: '', style: 'background-color:#22c55e' };
}
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function hourDateLabel(hourKey: string): string {
const mo = MONTH_NAMES[Number(hourKey.slice(5, 7)) - 1];
return `${mo} ${hourKey.slice(8, 10)}, ${hourKey.slice(11, 13)}:00 UTC`;
}
interface IncidentRange {
title: string;
created_at: string;
resolved_at: string | null;
}
// Returns the title of the newest incident covering this hour bucket (ISO
// strings compare lexicographically), or null.
function incidentTitleForHour(hourKey: string, incidents: IncidentRange[]): string | null {
const start = hourKey;
const end = hourKey.slice(0, 13) + ':59:59Z';
for (const inc of incidents) {
if (inc.created_at <= end && (inc.resolved_at === null || inc.resolved_at >= start)) {
return inc.title;
}
}
return null;
}
type ServiceStatusKey = 'operational' | 'degraded' | 'outage' | 'no_data' | 'disabled';
const SERVICE_STATUS_UI: Record<ServiceStatusKey, { cls: string; label: string }> = {
operational: { cls: 'bg-green-600', label: 'Operational' },
degraded: { cls: 'bg-yellow-500', label: 'Degraded' },
outage: { cls: 'bg-red-600', label: 'Outage' },
no_data: { cls: 'bg-gray-500', label: 'No data' },
disabled: { cls: 'bg-gray-400', label: 'Disabled' },
};
interface ServiceBatch {
total: number;
up: number;
}
// Current state follows the latest batch of checks — the checks recorded by
// the most recent scheduled run (service's own interval, min 60s). When no
// batch is in the window, fall back to the last known check result so a
// service shows its most recent state instead of "Unknown"; only services
// with no checks at all report no_data. Bars keep the 24h history for context.
function serviceStatus(
s: Service,
batch: ServiceBatch | null,
latest: LatestCheck | null,
): ServiceStatusKey {
if (s.enabled === 0) return 'disabled';
if (batch !== null && batch.total > 0) {
if (batch.up === 0) return 'outage';
if (batch.up < batch.total) return 'degraded';
return 'operational';
}
if (latest !== null) return latest.ok === 1 ? 'operational' : 'outage';
return 'no_data';
}
type OverallStatusKey = 'operational' | 'degraded' | 'outage' | 'unknown';
const OVERALL_STATUS_UI: Record<OverallStatusKey, { cls: string; label: string }> = {
operational: { cls: 'bg-green-600', label: 'Operational' },
degraded: { cls: 'bg-yellow-500', label: 'Degraded' },
outage: { cls: 'bg-red-600', label: 'Outage' },
unknown: { cls: 'bg-gray-500', label: 'Unknown' },
};
function overallStatusKey(
enabled: Service[],
statuses: Map<number, ServiceStatusKey>,
): OverallStatusKey {
// Unknown when no services are configured or any enabled service has no
// fresh batch; else Operational / Outage / Degraded from current states.
if (enabled.length === 0) return 'unknown';
const keys = enabled.map((s) => statuses.get(s.id) ?? 'no_data');
if (keys.some((k) => k === 'no_data')) return 'unknown';
if (keys.every((k) => k === 'operational')) return 'operational';
if (keys.every((k) => k === 'outage')) return 'outage';
return 'degraded';
}
// ---------------------------------------------------------------------------
// Shared snapshot (dashboard HTML + /status.json)
// ---------------------------------------------------------------------------
interface Snapshot {
services: Service[];
latest: Map<number, LatestCheck | null>;
statuses: Map<number, ServiceStatusKey>;
histories: Map<number, ServiceHistory>;
enabled: Service[];
overall: OverallStatusKey;
activeIncidents: Incident[];
activeUpdates: Map<number, IncidentUpdate[]>;
resolved: Incident[];
resolvedUpdates: Map<number, IncidentUpdate[]>;
chartIncidents: IncidentRange[];
}
async function loadSnapshot(env: Env): Promise<Snapshot> {
await ensureSchema(env);
const services = (
await env.DB.prepare('SELECT * FROM services ORDER BY name').all<Service>()
).results;
// Latest check (timestamps) + current-state batch (last 60s) + 24h bar
// history + 30-day uptime per service.
const latest = new Map<number, LatestCheck | null>();
const statuses = new Map<number, ServiceStatusKey>();
const histories = new Map<number, ServiceHistory>();
for (const s of services) {
latest.set(
s.id,
await env.DB.prepare(
`SELECT checked_at, ok, http_status, latency_ms FROM checks
WHERE service_id = ? ORDER BY checked_at DESC LIMIT 1`,
).bind(s.id).first<LatestCheck>() ?? null,
);
// Current-state batch: checks recorded within the service's own check
// interval (min 60s — the cron cadence), so state follows the most recent
// scheduled batch instead of a hardcoded window.
const windowSeconds = Math.max(s.interval_seconds, 60);
const batch = await env.DB.prepare(
`SELECT COUNT(*) AS total, SUM(CASE WHEN ok = 1 THEN 1 ELSE 0 END) AS up
FROM checks
WHERE service_id = ?1 AND checked_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?2)`,
).bind(s.id, `-${windowSeconds} seconds`).first<{ total: number; up: number }>();
statuses.set(
s.id,
serviceStatus(
s,
batch && batch.total > 0 ? { total: Number(batch.total), up: Number(batch.up) } : null,
latest.get(s.id) ?? null,
),
);
histories.set(s.id, await serviceHistory(env, s.id));
}
const enabled = services.filter((s) => s.enabled === 1);
const overall = overallStatusKey(enabled, statuses);
// Active incidents + their updates (newest update first).
const activeIncidents = (
await env.DB.prepare(
`SELECT * FROM incidents WHERE state != 'resolved' ORDER BY created_at DESC`,
).all<Incident>()
).results;
const activeUpdates = new Map<number, IncidentUpdate[]>();
if (activeIncidents.length > 0) {
const updates = (
await env.DB.prepare(
`SELECT * FROM incident_updates
WHERE incident_id IN (SELECT id FROM incidents WHERE state != 'resolved')
ORDER BY created_at DESC`,
).all<IncidentUpdate>()
).results;
for (const u of updates) {
const list = activeUpdates.get(u.incident_id) ?? [];
list.push(u);
activeUpdates.set(u.incident_id, list);
}
}
const resolved = (
await env.DB.prepare(
`SELECT * FROM incidents WHERE state = 'resolved' ORDER BY created_at DESC LIMIT 10`,
).all<Incident>()
).results;
const resolvedUpdates = new Map<number, IncidentUpdate[]>();
const resolvedRows = (
await env.DB.prepare(
`SELECT * FROM incident_updates
WHERE incident_id IN (
SELECT id FROM incidents WHERE state = 'resolved' ORDER BY created_at DESC LIMIT 10
)
ORDER BY created_at DESC`,
).all<IncidentUpdate>()
).results;
for (const u of resolvedRows) {
const list = resolvedUpdates.get(u.incident_id) ?? [];
list.push(u);
resolvedUpdates.set(u.incident_id, list);
}
// Incidents whose time range overlaps the 24h chart window (no LIMIT — an
// older but still-active/long-running incident must not be dropped).
const chartIncidents = (
await env.DB.prepare(
`SELECT title, created_at, resolved_at FROM incidents
WHERE created_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
AND (resolved_at IS NULL OR resolved_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day'))
ORDER BY created_at DESC`,
).all<IncidentRange>()
).results;
return { services, latest, statuses, histories, enabled, overall, activeIncidents, activeUpdates, resolved, resolvedUpdates, chartIncidents };
}
// ---------------------------------------------------------------------------
// Public JSON + RSS
// ---------------------------------------------------------------------------
// Machine-readable status for external consumers (e.g. the landing page).
async function statusJson(env: Env): Promise<Response> {
const snap = await loadSnapshot(env);
return json({
status: snap.overall,
services: snap.services.map((s) => {
const lc = snap.latest.get(s.id) ?? null;
const u30 = snap.histories.get(s.id)!.uptime30;
return {
id: s.id,
name: s.name,
enabled: s.enabled === 1,
status: snap.statuses.get(s.id) ?? 'no_data',
uptime30: u30 === null ? null : Math.round(u30 * 1000) / 1000,
lastCheckedAt: lc ? lc.checked_at : null,
};
}),
incidents: snap.activeIncidents.map((inc) => ({
id: inc.id,
title: inc.title,
description: inc.description,
state: inc.state,
createdAt: inc.created_at,
resolvedAt: inc.resolved_at,
updates: (snap.activeUpdates.get(inc.id) ?? []).map((u) => ({
id: u.id,
body: u.body,
state: u.state,
createdAt: u.created_at,
})),
})),
});
}
// Incident feed, RSS 2.0: one item per incident (active + resolved, newest
// first), description = incident description plus its update bodies.
async function rssFeed(env: Env): Promise<Response> {
await ensureSchema(env);
const incidents = (
await env.DB.prepare(
'SELECT * FROM incidents ORDER BY created_at DESC LIMIT 20',
).all<Incident>()
).results;
const updates = (
await env.DB.prepare('SELECT * FROM incident_updates ORDER BY created_at DESC').all<IncidentUpdate>()
).results;
const updatesByIncident = new Map<number, IncidentUpdate[]>();
for (const u of updates) {
const list = updatesByIncident.get(u.incident_id) ?? [];
list.push(u);
updatesByIncident.set(u.incident_id, list);
}
const items = incidents
.map((inc) => {
const badge = inc.state === 'resolved' ? '[Resolved]' : `[${stateLabel(inc.state)}]`;
const parts = [inc.description, ...(updatesByIncident.get(inc.id) ?? []).map((u) => u.body)].filter(Boolean);
const description = escapeHtml(stripControlChars(parts.join(' — ')));
return `<item>
<title>${escapeHtml(stripControlChars(badge + ' ' + inc.title))}</title>
<link>${SITE_URL}/</link>
<guid isPermaLink="false">incident-${inc.id}</guid>
<pubDate>${new Date(inc.created_at).toUTCString()}</pubDate>
<description>${description}</description>
</item>`;
})
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Tangled · Service Status</title>
<link>${SITE_URL}/</link>
<description>Uptime and incident updates for Tangled services.</description>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(xml, {
headers: { 'content-type': 'application/rss+xml; charset=utf-8' },
});
}
// ---------------------------------------------------------------------------
// Public dashboard
// ---------------------------------------------------------------------------
function incidentCard(inc: Incident, updates: IncidentUpdate[]): string {
const parts = [
`<div class="border border-gray-200 dark:border-gray-700 rounded p-4 mb-4">
<div class="flex items-center gap-2">
<span class="inline-flex items-center ${stateBadgeClass(inc.state)} text-white text-xs rounded px-2 py-0.5">${stateLabel(inc.state)}</span>
<span class="font-semibold">${escapeHtml(inc.title)}</span>
<span class="ml-auto text-sm text-gray-500 dark:text-gray-400">${fmt(inc.created_at)}</span>
</div>`,
];
if (inc.description) {
parts.push(`<p class="mt-2 text-sm">${escapeHtml(inc.description)}</p>`);
}
if (updates.length > 0) {
parts.push('<ul class="mt-3 space-y-2">');
for (const u of updates) {
parts.push(`<li class="text-sm border-l-2 pl-3 ${u.state ? 'border-gray-300 dark:border-gray-600' : 'border-gray-200 dark:border-gray-700'}">
${u.state ? `<span class="inline-flex items-center ${stateBadgeClass(u.state)} text-white text-xs rounded px-2 py-0.5 mr-2">${stateLabel(u.state)}</span>` : ''}<span>${escapeHtml(u.body)}</span>
<span class="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">${fmt(u.created_at)}</span>
</li>`);
}
parts.push('</ul>');
}
parts.push('</div>');
return parts.join('\n');
}
async function dashboard(env: Env): Promise<Response> {
const snap = await loadSnapshot(env);
const pill = OVERALL_STATUS_UI[snap.overall];
const body: string[] = [headerRow(pill.cls, pill.label)];
if (snap.activeIncidents.length > 0) {
body.push(`<section class="my-8">
<h2 class="text-lg font-semibold mb-3">Active incidents</h2>`);
for (const inc of snap.activeIncidents) {
body.push(incidentCard(inc, snap.activeUpdates.get(inc.id) ?? []));
}
body.push('</section>');
}
// Services
body.push(`<section class="my-8">
<h2 class="text-lg font-semibold mb-3">Services</h2>
<div class="border border-gray-200 dark:border-gray-700 rounded divide-y divide-gray-200 dark:divide-gray-700">`);
for (const s of snap.services) {
const hist = snap.histories.get(s.id)!;
const status = SERVICE_STATUS_UI[snap.statuses.get(s.id) ?? 'no_data'];
const bars = hist.buckets
.map((b, i) => {
const inc = incidentTitleForHour(hist.hourKeys[i], snap.chartIncidents);
const { cls, style } = barStyle(b, inc !== null);
const stats = b ? `${b.up}/${b.total} probes up` : 'no checks';
return `<div class="relative group h-full">
<div class="w-1.5 h-full rounded-sm ${cls}"${style ? ` style="${style}"` : ''}></div>
<div class="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:block z-10 whitespace-nowrap rounded bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs px-2 py-1 shadow">
<div class="font-semibold">${escapeHtml(hourDateLabel(hist.hourKeys[i]))}</div>
<div>${escapeHtml(stats)}</div>
${inc ? `<div class="mt-0.5">Incident: ${escapeHtml(inc)}</div>` : ''}
</div>
</div>`;
})
.join('');
body.push(`
<div class="flex flex-col md:flex-row md:items-center gap-3 px-4 py-3 md:gap-4">
<div class="flex items-center gap-3 md:block md:w-44 md:shrink-0 min-w-0">
<div class="font-medium truncate flex-1 md:flex-none">${escapeHtml(s.name)}</div>
<span class="mt-0 md:mt-1 inline-flex items-center rounded px-2 py-0.5 text-xs text-white ${status.cls} shrink-0">${status.label}</span>
</div>
<div class="flex items-end gap-0.5 h-8 w-full md:w-auto md:flex-1 min-w-0" aria-label="Uptime over the last 24 hours">
${bars}
</div>
<div class="text-right shrink-0">
<div class="text-lg font-semibold leading-tight">${hist.uptime30 === null ? '—' : formatUptime(hist.uptime30)}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">uptime · 30 days</div>
</div>
</div>`);
}
body.push('</div></section>');
if (snap.resolved.length > 0) {
body.push(`<section class="my-8">
<h2 class="text-lg font-semibold mb-3">Past incidents</h2>`);
for (const inc of snap.resolved) {
body.push(incidentCard(inc, snap.resolvedUpdates.get(inc.id) ?? []));
}
body.push('</section>');
}
return html(page('Tangled · Service Status', body.join('\n'), true));
}
// ---------------------------------------------------------------------------
// Admin UI
// ---------------------------------------------------------------------------
async function adminPage(env: Env): Promise<Response> {
await ensureSchema(env);
const services = (
await env.DB.prepare('SELECT * FROM services ORDER BY name').all<Service>()
).results;
const incidents = (
await env.DB.prepare(
`SELECT * FROM incidents WHERE state != 'resolved' ORDER BY created_at DESC`,
).all<Incident>()
).results;
const body: string[] = [
`<div class="flex items-center pt-8"><a href="/" class="no-underline hover:no-underline">${LOGOTYPE_SVG}</a></div>
<h1 style="font-size:30px;line-height:30px;font-weight:600" class="text-gray-900 dark:text-white pt-6 pb-4">Status admin</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Protected by Cloudflare Access. <a href="/" class="underline">Back to status page</a></p>`,
];
// New service
body.push(`<section class="my-8">
<h2 class="text-lg font-semibold mb-3">Add service</h2>
<form data-api="/api/services" class="grid gap-3 max-w-xl">
<label>Name<input name="name" required placeholder="appview"></label>
<label>URL<input name="url" required placeholder="https://appview.tangled.sh/"></label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label>Method<select name="method"><option>GET</option><option>HEAD</option><option>POST</option></select></label>
<label>Expected status<input name="expected_status" type="number" value="200" min="100" max="599"></label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label>Interval (seconds)<input name="interval_seconds" type="number" value="60" min="10"></label>
<label>Timeout (ms)<input name="timeout_ms" type="number" value="5000" min="100"></label>
</div>
<div><button class="btn btn-primary" type="submit">Add service</button></div>
</form>
</section>`);
// Services list
body.push(`<section class="my-8">
<h2 class="text-lg font-semibold mb-3">Services</h2>
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">`);
for (const m of services) {
body.push(`<li class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 px-4 py-2">
<div class="min-w-0 flex-1">
<span class="font-medium">${escapeHtml(m.name)}</span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">${m.enabled === 1 ? 'enabled' : 'disabled'}</span>
<div class="text-xs text-gray-500 dark:text-gray-400 break-all">${escapeHtml(m.url)}</div>
</div>
<div class="flex gap-2 shrink-0">
<button class="btn" data-action="/api/services/${m.id}/toggle">${m.enabled === 1 ? 'Disable' : 'Enable'}</button>
<button class="btn btn-cancel" data-action="/api/services/${m.id}" data-method="DELETE">Delete</button>
</div>
</li>`);
}
body.push('</ul></section>');
// New incident
body.push(`<section class="my-8">
<h2 class="text-lg font-semibold mb-3">New incident</h2>
<form data-api="/api/incidents" class="grid gap-3 max-w-xl">
<label>Title<input name="title" required placeholder="API latency spikes"></label>
<label>Description<textarea name="description" rows="3"></textarea></label>
<label>State<select name="state">
${STATES.map((s) => `<option value="${s}" ${s === 'investigating' ? 'selected' : ''}>${stateLabel(s)}</option>`).join('')}
</select></label>
<div><button class="btn btn-primary" type="submit">Create incident</button></div>
</form>
</section>`);
// Active incidents
body.push(`<section class="my-8">
<h2 class="text-lg font-semibold mb-3">Active incidents</h2>`);
if (incidents.length === 0) {
body.push('<p class="text-sm text-gray-500 dark:text-gray-400">None.</p>');
}
for (const inc of incidents) {
body.push(`
<div class="border border-gray-200 dark:border-gray-700 rounded p-4 mb-4">
<div class="flex items-center gap-2">
<span class="inline-flex items-center ${stateBadgeClass(inc.state)} text-white text-xs rounded px-2 py-0.5">${stateLabel(inc.state)}</span>
<span class="font-semibold">${escapeHtml(inc.title)}</span>
<span class="ml-auto text-sm text-gray-500 dark:text-gray-400">${fmt(inc.created_at)}</span>
</div>
${inc.description ? `<p class="mt-2 text-sm">${escapeHtml(inc.description)}</p>` : ''}
<form data-api="/api/incidents/${inc.id}/updates" class="mt-3 grid gap-2 max-w-xl">
<label>Update<textarea name="body" rows="2" required placeholder="What is happening?"></textarea></label>
<div class="flex flex-wrap items-center gap-3">
<label class="flex items-center gap-2 text-sm">State<select name="state" class="w-auto">
<option value="">no change</option>
${STATES.map((s) => `<option value="${s}">${stateLabel(s)}</option>`).join('')}
</select></label>
<button class="btn" type="submit">Post update</button>
<button class="btn btn-create" type="button" data-action="/api/incidents/${inc.id}/resolve">Resolve</button>
</div>
</form>
</div>`);
}
body.push('</section>');
const script = `
<script>
async function apiCall(path, method, body) {
const res = await fetch(path, {
method: method,
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let msg = 'HTTP ' + res.status;
try { msg = (await res.json()).error || msg; } catch (e) {}
alert(msg);
return false;
}
return true;
}
document.addEventListener('submit', function (e) {
const form = e.target;
if (!form.dataset.api) return;
e.preventDefault();
const body = {};
new FormData(form).forEach(function (v, k) { body[k] = v; });
if (body.state === '') delete body.state;
apiCall(form.dataset.api, 'POST', body).then(function (ok) { if (ok) location.reload(); });
});
document.addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
e.preventDefault();
apiCall(btn.dataset.action, btn.dataset.method || 'POST').then(function (ok) { if (ok) location.reload(); });
});
</script>`;
return html(page('Tangled · Status admin', body.join('\n') + script, false));
}
// ---------------------------------------------------------------------------
// JSON API (Cloudflare Access-scoped)
// ---------------------------------------------------------------------------
async function api(
request: Request,
env: Env,
path: string,
method: string,
): Promise<Response> {
if (method !== 'GET' && method !== 'HEAD' && !isSameOrigin(request)) {
return errorJson('cross-origin request rejected', 403);
}
try {
await ensureSchema(env);
if (path === '/api/services' && method === 'POST') {
return createService(env, request);
}
if (path === '/api/incidents' && method === 'POST') {
return createIncident(env, request);
}
let m = path.match(/^\/api\/services\/(\d+)\/toggle$/);
if (m && method === 'POST') {
return toggleService(env, Number(m[1]));
}
m = path.match(/^\/api\/services\/(\d+)$/);
if (m && method === 'DELETE') {
return deleteService(env, Number(m[1]));
}
m = path.match(/^\/api\/incidents\/(\d+)\/updates$/);
if (m && method === 'POST') {
return addIncidentUpdate(env, request, Number(m[1]));
}
m = path.match(/^\/api\/incidents\/(\d+)\/resolve$/);
if (m && method === 'POST') {
return resolveIncident(env, Number(m[1]));
}
if (/^\/api\/(services|incidents)(\/|$)/.test(path)) {
// Known API path with an unsupported method.
return errorJson('method not allowed', 405);
}
return errorJson('not found', 404);
} catch (e) {
return errorJson('internal error', 500);
}
}
async function createService(env: Env, request: Request): Promise<Response> {
const body = await readJsonObject(request);
if (!body) return errorJson('invalid JSON body', 400);
const name = typeof body.name === 'string' ? body.name.trim() : '';
const url = typeof body.url === 'string' ? body.url.trim() : '';
if (!name) return errorJson('name is required', 400);
if (!url) return errorJson('url is required', 400);
const method = methodOr(body.method);
const expectedStatus = intOr(body.expected_status, 200);
const interval = intOr(body.interval_seconds, 60);
const timeout = intOr(body.timeout_ms, 5000);
const res = await env.DB.prepare(
`INSERT INTO services (name, url, method, expected_status, interval_seconds, timeout_ms, enabled)
VALUES (?, ?, ?, ?, ?, ?, 1)`,
).bind(name, url, method, expectedStatus, interval, timeout).run();
const row = await env.DB.prepare('SELECT * FROM services WHERE id = ?')
.bind(res.meta.last_row_id).first<Service>();
return json(row);
}
async function toggleService(env: Env, id: number): Promise<Response> {
const res = await env.DB.prepare('UPDATE services SET enabled = NOT enabled WHERE id = ?')
.bind(id).run();
if (res.meta.changes === 0) return errorJson('service not found', 404);
const row = await env.DB.prepare('SELECT id, enabled FROM services WHERE id = ?')
.bind(id).first<{ id: number; enabled: number }>();
return json(row);
}
async function deleteService(env: Env, id: number): Promise<Response> {
const res = await env.DB.prepare('DELETE FROM services WHERE id = ?').bind(id).run();
if (res.meta.changes === 0) return errorJson('service not found', 404);
return json({ ok: true });
}
async function createIncident(env: Env, request: Request): Promise<Response> {
const body = await readJsonObject(request);
if (!body) return errorJson('invalid JSON body', 400);
const title = typeof body.title === 'string' ? body.title.trim() : '';
if (!title) return errorJson('title is required', 400);
const description = typeof body.description === 'string' ? body.description : '';
let state: IncidentState;
if (body.state === undefined) state = 'investigating';
else if (isState(body.state)) state = body.state;
else return errorJson('invalid state', 400);
const res = await env.DB.prepare(
'INSERT INTO incidents (title, description, state) VALUES (?, ?, ?)',
).bind(title, description, state).run();
const row = await env.DB.prepare('SELECT * FROM incidents WHERE id = ?')
.bind(res.meta.last_row_id).first<Incident>();
return json(row, 201);
}
async function addIncidentUpdate(
env: Env,
request: Request,
id: number,
): Promise<Response> {
const body = await readJsonObject(request);
if (!body) return errorJson('invalid JSON body', 400);
const text = typeof body.body === 'string' ? body.body.trim() : '';
if (!text) return errorJson('body is required', 400);
let state: IncidentState | null = null;
if (typeof body.state === 'string' && body.state !== '') {
if (!isState(body.state)) return errorJson('invalid state', 400);
state = body.state;
}
const exists = await env.DB.prepare('SELECT id FROM incidents WHERE id = ?')
.bind(id).first<{ id: number }>();
if (!exists) return errorJson('incident not found', 404);
const insert = env.DB.prepare(
'INSERT INTO incident_updates (incident_id, body, state) VALUES (?, ?, ?)',
).bind(id, text, state);
let rowId = 0;
if (state) {
const update = env.DB.prepare(
`UPDATE incidents SET
state = ?,
resolved_at = CASE WHEN ? = 'resolved' THEN strftime('%Y-%m-%dT%H:%M:%fZ', 'now') ELSE resolved_at END
WHERE id = ?`,
).bind(state, state, id);
const batch = await env.DB.batch([insert, update]);
rowId = Number(batch[0].meta.last_row_id);
} else {
const res = await insert.run();
rowId = Number(res.meta.last_row_id);
}
const row = await env.DB.prepare('SELECT * FROM incident_updates WHERE id = ?')
.bind(rowId).first<IncidentUpdate>();
return json(row);
}
async function resolveIncident(env: Env, id: number): Promise<Response> {
const exists = await env.DB.prepare('SELECT id FROM incidents WHERE id = ?')
.bind(id).first<{ id: number }>();
if (!exists) return errorJson('incident not found', 404);
const insert = env.DB.prepare(
'INSERT INTO incident_updates (incident_id, body, state) VALUES (?, ?, ?)',
).bind(id, 'Incident resolved', 'resolved');
const update = env.DB.prepare(
`UPDATE incidents SET state = 'resolved', resolved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?`,
).bind(id);
await env.DB.batch([insert, update]);
const row = await env.DB.prepare('SELECT * FROM incidents WHERE id = ?')
.bind(id).first<Incident>();
return json(row);
}
// ---------------------------------------------------------------------------
// Scheduled checks
// ---------------------------------------------------------------------------
interface DueService {
id: number;
url: string;
method: string;
expected_status: number;
interval_seconds: number;
timeout_ms: number;
}
async function runChecks(env: Env): Promise<void> {
await ensureSchema(env);
const rows = (
await env.DB.prepare(
`SELECT m.id, m.url, m.method, m.expected_status, m.interval_seconds, m.timeout_ms,
(SELECT MAX(checked_at) FROM checks c WHERE c.service_id = m.id) AS last
FROM services m WHERE m.enabled = 1`,
).all<DueService & { last: string | null }>()
).results;
const now = Date.now();
const due = rows.filter((r) => {
if (!r.last) return true;
return now - Date.parse(r.last) >= r.interval_seconds * 1000;
});
await Promise.allSettled(due.map((m) => runCheck(env, m)));
// Retention: keep 90 days of raw checks; idempotent, cheap. Runs even when
// individual checks failed (allSettled above).
await env.DB.prepare(
"DELETE FROM checks WHERE checked_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 days')",
).run();
}
async function runCheck(env: Env, m: DueService): Promise<void> {
const start = Date.now();
let ok = 0;
let httpStatus: number | null = null;
let latency: number | null = null;
let error: string | null = null;
try {
const res = await fetch(m.url, {
method: m.method,
headers: { 'user-agent': 'tangled-status/1.0' },
signal: AbortSignal.timeout(m.timeout_ms),
});
ok = res.status === m.expected_status ? 1 : 0;
httpStatus = res.status;
latency = Date.now() - start;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
await env.DB.prepare(
'INSERT INTO checks (service_id, ok, http_status, latency_ms, error) VALUES (?, ?, ?, ?, ?)',
).bind(m.id, ok, httpStatus, latency, error).run();
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
if (path === '/') {
if (method !== 'GET') return errorJson('method not allowed', 405);
return dashboard(env);
}
if (path === '/status.json') {
if (method !== 'GET') return errorJson('method not allowed', 405);
return statusJson(env);
}
if (path === '/rss.xml') {
if (method !== 'GET') return errorJson('method not allowed', 405);
return rssFeed(env);
}
if (path === '/admin' || path.startsWith('/api/')) {
// We assert access using team keys.
// If no team/audience configured, no admin!
if (env.ENVIRONMENT !== 'development') {
if (!(env.ACCESS_TEAM_DOMAIN && env.ACCESS_AUD)) {
return errorJson('access verification is not configured', 503);
}
if (
(await VerifiedAccessIdentity.verify(request.headers.get('cf-access-jwt-assertion'), env)) === null
) {
return errorJson('forbidden', 403);
}
}
}
if (path === '/admin') {
if (method !== 'GET') return errorJson('method not allowed', 405);
return adminPage(env);
}
if ((path.startsWith('/static/') || path === '/favicon.svg') && method !== 'GET' && method !== 'HEAD') {
return errorJson('method not allowed', 405);
}
if (path.startsWith('/static/')) {
// Fonts are bundled into the Worker (gitignored, not uploaded as
// assets); serve them from the bundle so `/static/fonts/*` matches the
// shared stylesheet's @font-face URLs.
if (path.startsWith('/static/fonts/')) {
const font = serveFont(path);
if (font) return font;
}
// The assets directory (`./static`) is served at the URL root, but the
// shared stylesheet's @font-face URLs are `/static/fonts/...`; translate
// `/static/<rest>` to the binding's asset path `/fonts/...` etc.
const assetUrl = new URL(request.url);
assetUrl.pathname = path.slice('/static'.length);
return env.ASSETS.fetch(assetUrl);
}
if (path === '/favicon.svg') {
return env.ASSETS.fetch(request);
}
if (path.startsWith('/api/')) {
return api(request, env, path, method);
}
// /static/* and /favicon.svg are served by the assets binding before the
// Worker runs; anything else is a miss.
return html('not found', 404);
},
async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext) {
const work = runChecks(env);
ctx.waitUntil(work);
await work;
},
} satisfies ExportedHandler<Env>;