Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
TypeScript
at sl/gitmirror
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172// 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<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 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 `<!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/')) { // 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/<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>;