mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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:
1 parent
d5452c0fce
commit
938dca96dd
4 files changed
+246
-22
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user