Fix(service-status): pick sub-services that say something

Supabase's card showed one region and Cloudflare's read "major
outage" on every visit. Supabase and five other providers are
incident.io pages, whose Statuspage-compatible summary flattens
groups away and, for Supabase, returns one component of 189; those
providers now also read the native /proxy/<host> structure and list
its top-level groups, each rolled up to its worst visible child.
Cloudflare's top level is seven continents that go red whenever one
PoP is re-routed, so its card lists a fixed set of products instead
and takes its light from them, not from the page indicator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Fable 5.1 committed 2026-10-21 23:59:50 +08:00
1 parent d5452c0fce
commit 938dca96dd
4 files changed
+246 -22

No files matched your search

+27 -11
View File
@@ -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/<host>`
// 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.
+14 -4
View File
@@ -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;
+60 -6
View File
@@ -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/<host>` 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/<host>`) 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,
+145 -1
View File
@@ -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', () => {