// 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-scoped at the edge; no auth code here): // 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 { 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; } 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 | 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 { 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 | 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; } 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, '''); } 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 hasAccessIdentity(request: Request): boolean { // Cloudflare Access sets `Cf-Access-Authenticated-User-Email` (default) and // `CF-Access-JWT-Assertion` on the origin request after a successful login. // Header lookup is case-insensitive. This is a fall-closed presence check; // signature verification of the JWT is handled by Access at the edge. return ( request.headers.get('cf-access-authenticated-user-email') !== null || request.headers.get('cf-access-jwt-assertion') !== null ); } 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 ` ${escapeHtml(title)} ${autoRefresh ? '\n' : ''}
${body}
${FOOTER_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 `

Service Status

${escapeHtml(pillLabel)}
`; } async function serviceHistory(env: Env, id: number): Promise { 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 = { 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 = { 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, ): 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; statuses: Map; histories: Map; enabled: Service[]; overall: OverallStatusKey; activeIncidents: Incident[]; activeUpdates: Map; resolved: Incident[]; resolvedUpdates: Map; chartIncidents: IncidentRange[]; } async function loadSnapshot(env: Env): Promise { await ensureSchema(env); const services = ( await env.DB.prepare('SELECT * FROM services ORDER BY name').all() ).results; // Latest check (timestamps) + current-state batch (last 60s) + 24h bar // history + 30-day uptime per service. const latest = new Map(); const statuses = new Map(); const histories = new Map(); 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() ?? 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() ).results; const activeUpdates = new Map(); 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() ).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() ).results; const resolvedUpdates = new Map(); 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() ).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() ).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 { 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 { await ensureSchema(env); const incidents = ( await env.DB.prepare( 'SELECT * FROM incidents ORDER BY created_at DESC LIMIT 20', ).all() ).results; const updates = ( await env.DB.prepare('SELECT * FROM incident_updates ORDER BY created_at DESC').all() ).results; const updatesByIncident = new Map(); 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 ` ${escapeHtml(stripControlChars(badge + ' ' + inc.title))} ${SITE_URL}/ incident-${inc.id} ${new Date(inc.created_at).toUTCString()} ${description} `; }) .join('\n'); const xml = ` Tangled · Service Status ${SITE_URL}/ Uptime and incident updates for Tangled services. ${new Date().toUTCString()} ${items} `; return new Response(xml, { headers: { 'content-type': 'application/rss+xml; charset=utf-8' }, }); } // --------------------------------------------------------------------------- // Public dashboard // --------------------------------------------------------------------------- function incidentCard(inc: Incident, updates: IncidentUpdate[]): string { const parts = [ `
${stateLabel(inc.state)} ${escapeHtml(inc.title)} ${fmt(inc.created_at)}
`, ]; if (inc.description) { parts.push(`

${escapeHtml(inc.description)}

`); } if (updates.length > 0) { parts.push('
    '); for (const u of updates) { parts.push(`
  • ${u.state ? `${stateLabel(u.state)}` : ''}${escapeHtml(u.body)} ${fmt(u.created_at)}
  • `); } parts.push('
'); } parts.push('
'); return parts.join('\n'); } async function dashboard(env: Env): Promise { 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(`

Active incidents

`); for (const inc of snap.activeIncidents) { body.push(incidentCard(inc, snap.activeUpdates.get(inc.id) ?? [])); } body.push('
'); } // Services body.push(`

Services

`); 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 `
`; }) .join(''); body.push(`
${escapeHtml(s.name)}
${status.label}
${bars}
${hist.uptime30 === null ? '—' : formatUptime(hist.uptime30)}
uptime · 30 days
`); } body.push('
'); if (snap.resolved.length > 0) { body.push(`

Past incidents

`); for (const inc of snap.resolved) { body.push(incidentCard(inc, snap.resolvedUpdates.get(inc.id) ?? [])); } body.push('
'); } return html(page('Tangled · Service Status', body.join('\n'), true)); } // --------------------------------------------------------------------------- // Admin UI // --------------------------------------------------------------------------- async function adminPage(env: Env): Promise { await ensureSchema(env); const services = ( await env.DB.prepare('SELECT * FROM services ORDER BY name').all() ).results; const incidents = ( await env.DB.prepare( `SELECT * FROM incidents WHERE state != 'resolved' ORDER BY created_at DESC`, ).all() ).results; const body: string[] = [ `

Status admin

Protected by Cloudflare Access. Back to status page

`, ]; // New service body.push(`

Add service

`); // Services list body.push(`

Services

    `); for (const m of services) { body.push(`
  • ${escapeHtml(m.name)} ${m.enabled === 1 ? 'enabled' : 'disabled'}
    ${escapeHtml(m.url)}
  • `); } body.push('
'); // New incident body.push(`

New incident

`); // Active incidents body.push(`

Active incidents

`); if (incidents.length === 0) { body.push('

None.

'); } for (const inc of incidents) { body.push(`
${stateLabel(inc.state)} ${escapeHtml(inc.title)} ${fmt(inc.created_at)}
${inc.description ? `

${escapeHtml(inc.description)}

` : ''}
`); } body.push('
'); const 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 { 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 { 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(); return json(row); } async function toggleService(env: Env, id: number): Promise { 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 { 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 { 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(); return json(row, 201); } async function addIncidentUpdate( env: Env, request: Request, id: number, ): Promise { 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(); return json(row); } async function resolveIncident(env: Env, id: number): Promise { 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(); 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 { 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() ).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 { 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 { 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/')) { // Admin surface is protected by Cloudflare Access at the edge; fail // closed here so it can never run unauthenticated. Access injects the // identity headers after login; outside production-local dev they are // required. (True edge enforcement still requires the Access app.) if (env.ENVIRONMENT !== 'development' && !hasAccessIdentity(request)) { 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/` 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;