Skip to content

Commit 2602c81

Browse files
W11: rewrite /status to consume real backend GET /api/v1/status (#61)
Replaces the previous client-side probe loop on /status with a server- side aggregate. Before this PR the page had a fatal failure mode caught by persona-3: when instanode's edge was down, the browser-side probe was ALSO down — the page either failed to load or reported green-on- green. Now the page: * Fetches /api/v1/status (60s cache, public, no auth) via the new fetchStatus() helper in src/api. * Renders one row per component with a 96-segment "last 24h" bar (15-min slots, green = healthy, red = unhealthy). * Surfaces a "Current incidents" section that maps the (today- empty) current_incidents array. When the incident-feed worker ships, real rows appear without a dashboard change. * Polls every 60s (matches the api TTL — polling faster yields the same bytes). * Falls through to an honest error banner on any fetch failure instead of crashing. Tests: 8 new in StatusPage.test.tsx covering row rendering, 96-slot bar, banner aggregation across operational/degraded/down, incidents empty + non-empty, and the two failure paths (ok=false, rejected promise). Companion PRs land in api (GET /api/v1/status, migration 035) and worker (uptime_prober). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bd4538a commit 2602c81

3 files changed

Lines changed: 532 additions & 241 deletions

File tree

src/api/index.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,89 @@ export async function reportExperimentConverted(input: {
256256
}
257257
}
258258

259+
// ─── Status — public, real backend (W11) ──────────────────────────────────
260+
// GET /api/v1/status returns the worker-aggregated uptime feed. Replaces
261+
// the previous client-side probe loop on /status (which had the fatal
262+
// "instanode edge down → probe also down" failure mode caught by P3).
263+
//
264+
// Public — no auth. Errors fall through to an empty payload so the page
265+
// renders a skeleton instead of a crash.
266+
267+
export interface StatusComponent {
268+
slug: string
269+
name: string
270+
category: string
271+
description?: string
272+
current_status: 'operational' | 'degraded' | 'down'
273+
uptime_7d_pct: number
274+
uptime_30d_pct: number
275+
/** 96 booleans, one per 15-minute slot, oldest → newest. */
276+
last_24h_samples: boolean[]
277+
}
278+
279+
export interface StatusIncident {
280+
id: string
281+
title: string
282+
severity: string
283+
status: string
284+
started_at: string
285+
resolved_at?: string
286+
summary?: string
287+
url?: string
288+
}
289+
290+
export interface StatusPayload {
291+
ok: boolean
292+
freshness_seconds: number
293+
as_of: string
294+
components: StatusComponent[]
295+
current_incidents: StatusIncident[]
296+
}
297+
298+
/**
299+
* fetchStatus — public GET /api/v1/status. Best-effort: on any failure
300+
* returns an honest empty payload (ok=false, components=[]) so the page
301+
* can render a degraded-but-functional skeleton instead of a 500 or a
302+
* console error. The page logic distinguishes ok=false from
303+
* components=[] (the latter is also a valid "fresh install, no probes
304+
* yet" state).
305+
*/
306+
export async function fetchStatus(): Promise<StatusPayload> {
307+
const base = getAPIBaseURL()
308+
const origin =
309+
base || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost')
310+
let url: string
311+
try {
312+
url = new URL('/api/v1/status', origin).toString()
313+
} catch {
314+
return emptyStatus()
315+
}
316+
try {
317+
const res = await fetch(url, { method: 'GET' })
318+
if (!res.ok) return emptyStatus()
319+
const body = (await res.json().catch(() => null)) as StatusPayload | null
320+
if (!body || !Array.isArray(body.components)) return emptyStatus()
321+
// Defensive: server should always send current_incidents, but coerce
322+
// missing/null to [] so the consumer can safely .map().
323+
if (!Array.isArray(body.current_incidents)) {
324+
body.current_incidents = []
325+
}
326+
return body
327+
} catch {
328+
return emptyStatus()
329+
}
330+
}
331+
332+
function emptyStatus(): StatusPayload {
333+
return {
334+
ok: false,
335+
freshness_seconds: 60,
336+
as_of: new Date().toISOString(),
337+
components: [],
338+
current_incidents: [],
339+
}
340+
}
341+
259342
export async function logout(): Promise<{ ok: true }> {
260343
clearToken()
261344
// Drop the admin URL prefix on logout. A stale prefix in module-local

src/pages/StatusPage.test.tsx

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/* StatusPage.test.tsx — wire shape from fetchStatus() drives the page.
2+
*
3+
* What we assert:
4+
* 1. Components render in the order returned by the api (no
5+
* browser-side re-sort).
6+
* 2. Each component's `last_24h_samples` becomes 96 slot elements.
7+
* 3. The aggregate banner reflects the worst component status.
8+
* 4. current_incidents=[] renders the "No active incidents" empty
9+
* state.
10+
* 5. current_incidents=[…] renders one row per incident with title
11+
* + severity + status pills.
12+
* 6. fetch failure (fetchStatus rejects / returns ok=false) renders
13+
* the degraded banner — never a crash.
14+
*/
15+
16+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
17+
import { render, screen, waitFor, cleanup } from '@testing-library/react'
18+
import { MemoryRouter } from 'react-router-dom'
19+
20+
// Mock the api module — the page imports fetchStatus as a named export.
21+
vi.mock('../api', async () => {
22+
const actual = await vi.importActual<typeof import('../api')>('../api')
23+
return {
24+
...actual,
25+
fetchStatus: vi.fn(),
26+
}
27+
})
28+
29+
import { StatusPage } from './StatusPage'
30+
import * as api from '../api'
31+
import type { StatusPayload } from '../api'
32+
33+
const mockFetchStatus = api.fetchStatus as unknown as ReturnType<typeof vi.fn>
34+
35+
function payload(overrides: Partial<StatusPayload> = {}): StatusPayload {
36+
return {
37+
ok: true,
38+
freshness_seconds: 60,
39+
as_of: '2026-05-14T05:30:00Z',
40+
components: [
41+
{
42+
slug: 'api',
43+
name: 'API',
44+
category: 'core',
45+
description: 'instanode API',
46+
current_status: 'operational',
47+
uptime_7d_pct: 99.95,
48+
uptime_30d_pct: 99.92,
49+
last_24h_samples: Array(96).fill(true),
50+
},
51+
{
52+
slug: 'marketing',
53+
name: 'Marketing',
54+
category: 'edge',
55+
description: 'instanode.dev',
56+
current_status: 'operational',
57+
uptime_7d_pct: 100,
58+
uptime_30d_pct: 100,
59+
last_24h_samples: Array(96).fill(true),
60+
},
61+
],
62+
current_incidents: [],
63+
...overrides,
64+
}
65+
}
66+
67+
beforeEach(() => {
68+
mockFetchStatus.mockReset()
69+
mockFetchStatus.mockResolvedValue(payload())
70+
})
71+
afterEach(() => cleanup())
72+
73+
function withRouter(ui: React.ReactNode) {
74+
return <MemoryRouter>{ui}</MemoryRouter>
75+
}
76+
77+
describe('StatusPage', () => {
78+
it('renders one row per component from fetchStatus, in api order', async () => {
79+
render(withRouter(<StatusPage />))
80+
await waitFor(() => {
81+
expect(screen.getByTestId('status-row-api')).toBeTruthy()
82+
expect(screen.getByTestId('status-row-marketing')).toBeTruthy()
83+
})
84+
// Order matches the api response — api row appears before
85+
// marketing row in document order.
86+
const rows = screen.getAllByRole('listitem')
87+
expect(rows[0].getAttribute('data-testid')).toBe('status-row-api')
88+
expect(rows[1].getAttribute('data-testid')).toBe('status-row-marketing')
89+
})
90+
91+
it('renders 96 slot elements per component (24h × 15min)', async () => {
92+
render(withRouter(<StatusPage />))
93+
await waitFor(() => {
94+
const bar = screen.getByTestId('uptime-bar-api')
95+
expect(bar.children.length).toBe(96)
96+
})
97+
})
98+
99+
it('flips the banner to degraded when any component is not operational', async () => {
100+
mockFetchStatus.mockResolvedValue(
101+
payload({
102+
components: [
103+
{
104+
slug: 'api',
105+
name: 'API',
106+
category: 'core',
107+
current_status: 'degraded',
108+
uptime_7d_pct: 98.5,
109+
uptime_30d_pct: 99.0,
110+
last_24h_samples: Array(96).fill(true),
111+
},
112+
],
113+
}),
114+
)
115+
const { container } = render(withRouter(<StatusPage />))
116+
await waitFor(() => {
117+
const banner = container.querySelector('.status-banner--degraded')
118+
expect(banner).toBeTruthy()
119+
expect(banner?.textContent || '').toMatch(/1 service affected/)
120+
})
121+
})
122+
123+
it('flips the banner to "down" when any component is down', async () => {
124+
mockFetchStatus.mockResolvedValue(
125+
payload({
126+
components: [
127+
{
128+
slug: 'api',
129+
name: 'API',
130+
category: 'core',
131+
current_status: 'down',
132+
uptime_7d_pct: 50,
133+
uptime_30d_pct: 90,
134+
last_24h_samples: Array(96).fill(false),
135+
},
136+
],
137+
}),
138+
)
139+
const { container } = render(withRouter(<StatusPage />))
140+
await waitFor(() => {
141+
const banner = container.querySelector('.status-banner--down')
142+
expect(banner).toBeTruthy()
143+
})
144+
})
145+
146+
it('shows the "no active incidents" empty state when current_incidents is empty', async () => {
147+
render(withRouter(<StatusPage />))
148+
await waitFor(() => {
149+
expect(screen.getByTestId('incidents-empty')).toBeTruthy()
150+
})
151+
})
152+
153+
it('renders one row per incident when current_incidents is non-empty', async () => {
154+
mockFetchStatus.mockResolvedValue(
155+
payload({
156+
current_incidents: [
157+
{
158+
id: 'inc-1',
159+
title: 'Provisioner gRPC dropping connections',
160+
severity: 'major',
161+
status: 'investigating',
162+
started_at: '2026-05-14T04:00:00Z',
163+
summary: 'Customer database provisioning is failing intermittently.',
164+
},
165+
{
166+
id: 'inc-2',
167+
title: 'Marketing site slow',
168+
severity: 'minor',
169+
status: 'monitoring',
170+
started_at: '2026-05-14T03:00:00Z',
171+
},
172+
],
173+
}),
174+
)
175+
render(withRouter(<StatusPage />))
176+
await waitFor(() => {
177+
const list = screen.getByTestId('status-incidents')
178+
// Two incidents → two .status-incident-row children.
179+
expect(list.children.length).toBe(2)
180+
expect(screen.getByText(/Provisioner gRPC dropping/)).toBeTruthy()
181+
expect(screen.getByText(/Marketing site slow/)).toBeTruthy()
182+
})
183+
})
184+
185+
it('surfaces an error banner when fetchStatus returns ok=false', async () => {
186+
mockFetchStatus.mockResolvedValue({
187+
ok: false,
188+
freshness_seconds: 60,
189+
as_of: new Date().toISOString(),
190+
components: [],
191+
current_incidents: [],
192+
})
193+
render(withRouter(<StatusPage />))
194+
await waitFor(() => {
195+
// The error banner uses role="alert".
196+
expect(screen.getByRole('alert')).toBeTruthy()
197+
})
198+
})
199+
200+
it('does NOT crash when fetchStatus rejects', async () => {
201+
mockFetchStatus.mockRejectedValue(new Error('boom'))
202+
render(withRouter(<StatusPage />))
203+
// The page must render even before / despite a fetch failure.
204+
await waitFor(() => {
205+
expect(screen.getByRole('alert')).toBeTruthy()
206+
})
207+
})
208+
})

0 commit comments

Comments
 (0)