diff --git a/server/service-status-providers.js b/server/service-status-providers.js index 2dc8b3d1..f529d5a7 100644 --- a/server/service-status-providers.js +++ b/server/service-status-providers.js @@ -1,4 +1,4 @@ -// Upstream source-of-truth for the "Service Status" homepage section. +// Upstream source-of-truth for the "Service Status" advanced tool page. // // Each provider exposes a status page that is API-compatible with the Atlassian // Statuspage / incident.io schema — both expose the same @@ -11,6 +11,8 @@ // `page` — public status page; also the base for incident detail links // (`${page}/incidents/${incidentId}`), since incident.io's feed omits // a usable shortlink. +// `platform` — 'incident.io': components come from the native `/proxy/` +// structure, since the compat summary flattens groups away. // // This list lives backend-side: the poller (server/service-status-store.js) // walks it on a fixed schedule and caches the result in memory, so the @@ -18,24 +20,38 @@ export const STATUS_PROVIDERS = [ { id: 'claude', name: 'Claude', api: 'https://status.claude.com', page: 'https://status.claude.com' }, - { id: 'chatgpt', name: 'ChatGPT', api: 'https://status.openai.com', page: 'https://status.openai.com' }, + { id: 'chatgpt', name: 'ChatGPT', api: 'https://status.openai.com', page: 'https://status.openai.com', platform: 'incident.io' }, { id: 'cursor', name: 'Cursor', api: 'https://status.cursor.com', page: 'https://status.cursor.com' }, // exclude: drop GitHub's "Visit … for more information" placeholder row. { id: 'github', name: 'GitHub', api: 'https://www.githubstatus.com', page: 'https://www.githubstatus.com', components: { exclude: ['0l2p9nhqnxpd'] } }, - { id: 'elevenlabs', name: 'ElevenLabs', api: 'https://status.elevenlabs.io', page: 'https://status.elevenlabs.io' }, - // Default top-level: summary.json flattens away CF's mid-level product - // categories, leaving one "Cloudflare Sites and Services" rollup + 7 - // continents — which keeps the card light consistent with this list. - { id: 'cloudflare', name: 'Cloudflare', api: 'https://new.cloudflarestatus.com', page: 'https://new.cloudflarestatus.com' }, - { id: 'groq', name: 'Groq', api: 'https://groqstatus.com', page: 'https://groqstatus.com' }, - { id: 'notion', name: 'Notion', api: 'https://www.notion-status.com', page: 'https://www.notion-status.com' }, + { id: 'elevenlabs', name: 'ElevenLabs', api: 'https://status.elevenlabs.io', page: 'https://status.elevenlabs.io', platform: 'incident.io' }, + // The continent groups roll up every re-routed PoP, so CF's page indicator + // is permanently major: list products instead and light the card from them. + { + id: 'cloudflare', name: 'Cloudflare', api: 'https://www.cloudflarestatus.com', page: 'https://www.cloudflarestatus.com', + components: { + include: [ + 'CDN/Cache', 'Authoritative DNS', 'Recursive DNS', 'Network', + 'SSL Certificate Provisioning', 'Argo Smart Routing', + 'Load Balancing and Monitoring', 'Spectrum', 'Magic Transit', 'Firewall', + 'Bot Management', 'Challenge Platform', 'Turnstile', 'Waiting Room', + 'Dashboard', 'API', 'Registrar', 'Zero Trust', 'Access', 'Gateway', + 'Tunnel', 'WARP', 'Workers', 'Workers KV', 'Workers AI', 'AI Gateway', + 'Pages', 'R2', 'D1', 'Durable Objects', 'Queues', 'Stream', 'Images', + 'Email Routing', 'Radar', + ], + indicator: 'components', + }, + }, + { id: 'groq', name: 'Groq', api: 'https://groqstatus.com', page: 'https://groqstatus.com', platform: 'incident.io' }, + { id: 'notion', name: 'Notion', api: 'https://www.notion-status.com', page: 'https://www.notion-status.com', platform: 'incident.io' }, { id: 'vercel', name: 'Vercel', api: 'https://www.vercel-status.com', page: 'https://www.vercel-status.com' }, { id: 'netlify', name: 'Netlify', api: 'https://www.netlifystatus.com', page: 'https://www.netlifystatus.com' }, { id: 'render', name: 'Render', api: 'https://status.render.com', page: 'https://status.render.com' }, - { id: 'supabase', name: 'Supabase', api: 'https://status.supabase.com', page: 'https://status.supabase.com' }, + { id: 'supabase', name: 'Supabase', api: 'https://status.supabase.com', page: 'https://status.supabase.com', platform: 'incident.io' }, { id: 'discord', name: 'Discord', api: 'https://discordstatus.com', page: 'https://discordstatus.com' }, { id: 'figma', name: 'Figma', api: 'https://status.figma.com', page: 'https://status.figma.com' }, - { id: 'linear', name: 'Linear', api: 'https://linearstatus.com', page: 'https://linearstatus.com' }, + { id: 'linear', name: 'Linear', api: 'https://linearstatus.com', page: 'https://linearstatus.com', platform: 'incident.io' }, // Stripe's customer-facing status.stripe.com is a homegrown page with a // non-Statuspage schema; www.stripestatus.com is its Statuspage mirror, // which exposes the /api/v2 endpoints this poller needs. diff --git a/server/service-status-store.js b/server/service-status-store.js index 358dc044..3264bbf3 100644 --- a/server/service-status-store.js +++ b/server/service-status-store.js @@ -3,8 +3,8 @@ // Rather than fetch upstream status pages on each visitor request, a single // background timer refreshes all providers every REFRESH_INTERVAL and keeps // the latest normalized snapshot in memory. Request handlers just read the -// snapshot — upstream load is constant (16 providers × 2 endpoints / 5 min) -// no matter how much traffic the site gets. +// snapshot — upstream load is constant (16 providers × 2 endpoints / 5 min, +// +1 per incident.io page) no matter how much traffic the site gets. // // Mirrors the bootstrap-at-boot + start-scheduler shape of the MaxMind / CAIDA // updaters, but kept deliberately lean: memory only, no files, no locks. @@ -38,12 +38,17 @@ async function fetchJson(url) { // unavailable". Only the genuinely-degraded case (no prior good data) is a // warn; recoverable blips drop to debug to keep the prod log quiet. async function refreshProvider(provider, previous) { - const [summary, incidents] = await Promise.allSettled([ + const structureUrl = provider.platform === 'incident.io' + ? `${provider.api}/proxy/${new URL(provider.api).host}` + : null; + const [summary, incidents, structure] = await Promise.allSettled([ fetchJson(`${provider.api}/api/v2/summary.json`), fetchJson(`${provider.api}/api/v2/incidents.json`), + structureUrl ? fetchJson(structureUrl) : null, ]); const summaryJson = summary.status === 'fulfilled' ? summary.value : null; const incidentsJson = incidents.status === 'fulfilled' ? incidents.value : null; + const structureJson = structure.status === 'fulfilled' ? structure.value : null; if (!summaryJson) { const hadGood = previous && previous.indicator !== 'unknown'; @@ -54,7 +59,12 @@ async function refreshProvider(provider, previous) { logger.warn({ err: summary.reason, provider: provider.id }, 'service-status summary fetch failed (no prior data)'); } - const entry = assembleProvider(provider, summaryJson, incidentsJson); + // A failed structure fetch falls back to the summary's own components. + if (structureUrl && !structureJson && summaryJson) { + logger.debug({ err: structure.reason, provider: provider.id }, 'service-status structure fetch failed; using summary components'); + } + + const entry = assembleProvider(provider, summaryJson, incidentsJson, structureJson); // Keep prior incidents if only the incidents endpoint blipped this tick. if (!incidentsJson && previous?.incidents?.length) { entry.incidents = previous.incidents; diff --git a/server/service-status-transform.js b/server/service-status-transform.js index 4522722d..4d7227ba 100644 --- a/server/service-status-transform.js +++ b/server/service-status-transform.js @@ -15,6 +15,7 @@ const COMPONENT_LIMIT = 40; // { flatten: true } every leaf, group headers dropped // { include: [names] } ordered whitelist by component name // { exclude: [id|name] } final filter on top of any mode (drops junk rows) +// { indicator: 'components' } card light from the selected rows, not the page's function selectComponents(raw, selector = {}) { let picked; if (Array.isArray(selector.include)) { @@ -33,18 +34,69 @@ function selectComponents(raw, selector = {}) { return picked; } +// Severity order for "worst status"; an unknown string ranks below operational. +const STATUS_RANK = { operational: 0, under_maintenance: 1, degraded_performance: 2, partial_outage: 3, major_outage: 4 }; + +const worstStatus = (statuses) => statuses.reduce( + (worst, s) => ((STATUS_RANK[s] ?? -1) > STATUS_RANK[worst] ? s : worst), + 'operational', +); + +// Worst component status → page indicator, matching the frontend tone map. +export const indicatorFromComponents = (components) => { + if (!Array.isArray(components) || !components.length) return 'unknown'; + switch (worstStatus(components.map((c) => c?.status))) { + case 'major_outage': + return 'major'; + case 'partial_outage': + case 'degraded_performance': + return 'minor'; + case 'under_maintenance': + return 'maintenance'; + default: + return 'none'; + } +}; + +// incident.io's native `/proxy/` payload → Statuspage-style top-level rows +// `{ id, name, status }`; a group takes its worst visible child. Only +// non-operational components are listed in `affected_components`. +export const componentsFromStructure = (proxyJson) => { + const items = proxyJson?.summary?.structure?.items; + if (!Array.isArray(items)) return []; + const affected = proxyJson.summary.affected_components; + const statusById = new Map((Array.isArray(affected) ? affected : []).map((a) => [a?.component_id, a?.status])); + const statusOf = (id) => statusById.get(id) || 'operational'; + + return items.flatMap((item) => { + if (item?.group) { + const { id, name, hidden, components } = item.group; + if (hidden) return []; + const visible = (Array.isArray(components) ? components : []).filter((c) => c && !c.hidden); + return [{ id, name, status: worstStatus(visible.map((c) => statusOf(c.component_id))) }]; + } + const c = item?.component; + if (!c || c.hidden) return []; + return [{ id: c.component_id, name: c.name, status: statusOf(c.component_id) }]; + }); +}; + // Normalize a `/api/v2/summary.json` payload to // { indicator, components: [{ name, status }] } // where indicator is 'none' | 'minor' | 'major' | 'critical' | 'maintenance' | 'unknown'. -// `selector` chooses which components to keep (see selectComponents). -export function normalizeSummary(json, selector = {}) { - const raw = Array.isArray(json?.components) ? json.components : []; +// `selector` chooses which components to keep (see selectComponents); +// `rawComponents` replaces `json.components` as the rows to select from. +export function normalizeSummary(json, selector = {}, rawComponents = null) { + const raw = Array.isArray(rawComponents) ? rawComponents + : Array.isArray(json?.components) ? json.components : []; const components = selectComponents(raw, selector) .slice(0, COMPONENT_LIMIT) .map((c) => ({ name: c?.name ?? '', status: c?.status ?? 'operational' })); return { - indicator: json?.status?.indicator || 'unknown', + indicator: selector?.indicator === 'components' + ? indicatorFromComponents(components) + : json?.status?.indicator || 'unknown', components, }; } @@ -96,8 +148,10 @@ function incidentTimestamp(incident) { // fetch) degrades gracefully — normalizeSummary(null) → indicator 'unknown' // with no components, normalizeIncidents(null) → []. Keeps the poller's // per-provider error handling free of shape-building logic. -export function assembleProvider(provider, summaryJson, incidentsJson) { - const { indicator, components } = normalizeSummary(summaryJson, provider.components); +// `structureJson` (incident.io `/proxy/`) supplies the rows when it yields any. +export function assembleProvider(provider, summaryJson, incidentsJson, structureJson = null) { + const rows = structureJson ? componentsFromStructure(structureJson) : []; + const { indicator, components } = normalizeSummary(summaryJson, provider.components, rows.length ? rows : null); return { id: provider.id, name: provider.name, diff --git a/tests/service-status-transform.test.js b/tests/service-status-transform.test.js index aaf26777..5b5d31a9 100644 --- a/tests/service-status-transform.test.js +++ b/tests/service-status-transform.test.js @@ -4,7 +4,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { normalizeSummary, normalizeIncidents, assembleProvider } from '../server/service-status-transform.js'; +import { + normalizeSummary, normalizeIncidents, assembleProvider, + componentsFromStructure, indicatorFromComponents, +} from '../server/service-status-transform.js'; import { indicatorToTone, componentStatusToTone, impactLevel, incidentStatusTone, } from '../frontend/utils/features/service-status-tone.js'; @@ -75,6 +78,112 @@ describe('normalizeSummary', () => { assert.equal(out.indicator, 'unknown'); assert.deepEqual(out.components, []); }); + + it('indicator "components" derives the light from the selected rows', () => { + const json = { + status: { indicator: 'major' }, + components: [ + { id: 'a', name: 'CDN', status: 'operational' }, + { id: 'b', name: 'DNS', status: 'operational' }, + { id: 'c', name: 'Workers', status: 'partial_outage' }, + { id: 'd', name: 'Europe PoP', status: 'major_outage' }, + ], + }; + const healthy = normalizeSummary(json, { include: ['CDN', 'DNS'], indicator: 'components' }); + assert.equal(healthy.indicator, 'none'); + const partial = normalizeSummary(json, { include: ['CDN', 'Workers'], indicator: 'components' }); + assert.equal(partial.indicator, 'minor'); + // Without the flag, the page's own indicator wins. + assert.equal(normalizeSummary(json, { include: ['CDN'] }).indicator, 'major'); + }); +}); + +describe('componentsFromStructure', () => { + const proxy = (items, affected = []) => ({ + summary: { structure: { items }, affected_components: affected }, + }); + + it('rolls a group up to its worst child; bare components carry their own status', () => { + const out = componentsFromStructure(proxy([ + { group: { id: 'g1', name: 'APIs', hidden: false, components: [ + { component_id: 'c1', name: 'Chat', hidden: false }, + { component_id: 'c2', name: 'Files', hidden: false }, + { component_id: 'c3', name: 'Batch', hidden: false }, + ] } }, + { component: { component_id: 'c4', name: 'Auth', hidden: false } }, + { component: { component_id: 'c5', name: 'Billing', hidden: false } }, + ], [ + { component_id: 'c1', status: 'degraded_performance' }, + { component_id: 'c2', status: 'major_outage' }, + { component_id: 'c3', status: 'under_maintenance' }, + { component_id: 'c5', status: 'partial_outage' }, + ])); + assert.deepEqual(out, [ + { id: 'g1', name: 'APIs', status: 'major_outage' }, + { id: 'c4', name: 'Auth', status: 'operational' }, + { id: 'c5', name: 'Billing', status: 'partial_outage' }, + ]); + }); + + it('skips hidden items and hidden children', () => { + const out = componentsFromStructure(proxy([ + { group: { id: 'g1', name: 'Core', hidden: false, components: [ + { component_id: 'c1', name: 'Visible', hidden: false }, + { component_id: 'c2', name: 'Secret', hidden: true }, + ] } }, + { group: { id: 'g2', name: 'Hidden group', hidden: true, components: [] } }, + { component: { component_id: 'c3', name: 'Hidden leaf', hidden: true } }, + ], [{ component_id: 'c2', status: 'major_outage' }])); + assert.deepEqual(out, [{ id: 'g1', name: 'Core', status: 'operational' }]); + }); + + it('treats unlisted children, empty groups and unknown statuses as operational', () => { + const out = componentsFromStructure(proxy([ + { group: { id: 'g1', name: 'Quiet', hidden: false, components: [ + { component_id: 'c1', name: 'A', hidden: false }, + { component_id: 'c2', name: 'B', hidden: false }, + ] } }, + { group: { id: 'g2', name: 'Empty', hidden: false, components: [] } }, + { group: { id: 'g3', name: 'Odd', hidden: false, components: [ + { component_id: 'c3', name: 'C', hidden: false }, + ] } }, + ], [{ component_id: 'c3', status: 'full_meltdown' }])); + assert.deepEqual(out.map((c) => c.status), ['operational', 'operational', 'operational']); + }); + + it('keeps the structure order', () => { + const out = componentsFromStructure(proxy([ + { component: { component_id: 'z', name: 'Zeta' } }, + { group: { id: 'a', name: 'Alpha', components: [] } }, + { component: { component_id: 'm', name: 'Mu' } }, + ])); + assert.deepEqual(out.map((c) => c.name), ['Zeta', 'Alpha', 'Mu']); + }); + + it('returns [] on null or malformed input', () => { + assert.deepEqual(componentsFromStructure(null), []); + assert.deepEqual(componentsFromStructure({}), []); + assert.deepEqual(componentsFromStructure({ summary: { structure: { items: 'x' } } }), []); + assert.deepEqual(componentsFromStructure(proxy([null, {}, { group: null }])), []); + }); +}); + +describe('indicatorFromComponents', () => { + const rows = (...statuses) => statuses.map((status, i) => ({ name: `c${i}`, status })); + + it('maps the worst status to a page indicator', () => { + assert.equal(indicatorFromComponents(rows('operational', 'major_outage', 'partial_outage')), 'major'); + assert.equal(indicatorFromComponents(rows('operational', 'partial_outage')), 'minor'); + assert.equal(indicatorFromComponents(rows('degraded_performance', 'under_maintenance')), 'minor'); + assert.equal(indicatorFromComponents(rows('operational', 'under_maintenance')), 'maintenance'); + assert.equal(indicatorFromComponents(rows('operational', 'operational')), 'none'); + assert.equal(indicatorFromComponents(rows('operational', 'mystery')), 'none'); + }); + + it('is "unknown" for an empty list', () => { + assert.equal(indicatorFromComponents([]), 'unknown'); + assert.equal(indicatorFromComponents(null), 'unknown'); + }); }); describe('normalizeIncidents', () => { @@ -172,6 +281,41 @@ describe('assembleProvider', () => { // flatten drops the header, keeps the leaf. assert.deepEqual(out.components.map((c) => c.name), ['Leaf']); }); + + describe('with an incident.io structure payload', () => { + const summaryJson = { + status: { indicator: 'minor' }, + components: [{ id: 'x', name: 'Flattened leaf', status: 'operational' }], + }; + const structureJson = { + summary: { + structure: { items: [ + { group: { id: 'g1', name: 'APIs', components: [{ component_id: 'c1', name: 'Chat' }] } }, + { component: { component_id: 'c2', name: 'Codex' } }, + ] }, + affected_components: [{ component_id: 'c1', status: 'degraded_performance' }], + }, + }; + + it('takes the rows from the structure, the indicator from the summary', () => { + const out = assembleProvider(provider, summaryJson, null, structureJson); + assert.deepEqual(out.components, [ + { name: 'APIs', status: 'degraded_performance' }, + { name: 'Codex', status: 'operational' }, + ]); + assert.equal(out.indicator, 'minor'); + }); + + it('still applies the include selector by name', () => { + const out = assembleProvider({ ...provider, components: { include: ['Codex'] } }, summaryJson, null, structureJson); + assert.deepEqual(out.components.map((c) => c.name), ['Codex']); + }); + + it('falls back to the summary components when the structure yields no rows', () => { + const out = assembleProvider(provider, summaryJson, null, { summary: {} }); + assert.deepEqual(out.components.map((c) => c.name), ['Flattened leaf']); + }); + }); }); describe('tone mapping', () => {