Skip to content

Commit c4e787b

Browse files
feat(dashboard): MetricsPanel + wire /api/v1/resources/:id/metrics (W7-F) (#55)
* feat(dashboard): MetricsPanel component + wire /api/v1/resources/:id/metrics W7-F dashboard half. Adds: - src/components/MetricsPanel.tsx — chart tiles for p50/p95/p99 latency, active connections, storage_bytes, error_rate over a tier-capped window. Polls /api/v1/resources/:id/metrics every 60s. - src/api/index.ts — getResourceMetrics(id, window) typed client helper. - ResourceDetailPage Metrics tab now renders MetricsPanel instead of the 'awaiting backend' placeholder. - e2e/resources.spec.ts — Playwright coverage for the Metrics tab rendering with a stub data_source banner. Pairs with W7-F-api (POST /api/v1/resources/:id/metrics). If the api half isn't merged yet, the panel renders an empty-state — not a hard failure. * test(dashboard): update ResourceDetailPage.test.tsx for live metrics row W7-F now ships /metrics as status="live" while keeping audit as gap with its own early-access CTA (testid: audit-early-access). Update the ContractLine assertions: 4 live rows (was 3), 1 gap row (was 0).
1 parent 5d343d4 commit c4e787b

6 files changed

Lines changed: 602 additions & 51 deletions

File tree

e2e/resources.spec.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from '@playwright/test'
1+
import { expect, test, Route } from '@playwright/test'
22
import { FAKE_RESOURCES, installAPIFake, signIn } from './fixtures'
33

44
test.describe('Resources', () => {
@@ -22,4 +22,48 @@ test.describe('Resources', () => {
2222
// The h1 carries the resource name in the new design.
2323
await expect(page.getByRole('heading', { level: 1, name: r.name! })).toBeVisible()
2424
})
25+
26+
test('Metrics tab renders charts instead of the prior "gap" placeholder', async ({ page }) => {
27+
const r = FAKE_RESOURCES[0]
28+
29+
// Mock the metrics endpoint with a fake stub response — exercises the
30+
// banner branch AND the chart layout (the W7F panel must render both).
31+
await page.route(`**/api/v1/resources/${r.token}/metrics**`, (route: Route) =>
32+
route.fulfill({
33+
status: 200,
34+
contentType: 'application/json',
35+
body: JSON.stringify({
36+
ok: true,
37+
resource_id: r.id,
38+
resource_type: r.resource_type,
39+
window_seconds: 3600,
40+
samples_count: 60,
41+
sample_interval_seconds: 60,
42+
metrics: {
43+
latency_p50_ms: Array.from({ length: 60 }, (_, i) => 2 + i * 0.01),
44+
latency_p95_ms: Array.from({ length: 60 }, (_, i) => 8 + i * 0.02),
45+
latency_p99_ms: Array.from({ length: 60 }, (_, i) => 18 + i * 0.04),
46+
connections_active: Array.from({ length: 60 }, () => 3),
47+
storage_bytes: Array.from({ length: 60 }, (_, i) => 1_000_000 + i * 50_000),
48+
error_rate_pct: Array.from({ length: 60 }, () => 0),
49+
},
50+
data_source: 'stub',
51+
}),
52+
}),
53+
)
54+
55+
await page.goto(`/resources/${r.token}`)
56+
await page.getByRole('button', { name: 'Metrics' }).click()
57+
58+
// The prior placeholder said "awaiting backend" — must be gone.
59+
await expect(page.getByText('awaiting backend')).toHaveCount(0)
60+
await expect(page.getByText('no data source')).toHaveCount(0)
61+
62+
// The MetricsPanel renders. Charts present.
63+
await expect(page.getByTestId('metrics-panel')).toBeVisible()
64+
await expect(page.getByTestId('metrics-storage-tile')).toBeVisible()
65+
66+
// The stub banner explains the resource hasn't seen probes yet.
67+
await expect(page.getByTestId('metrics-stub-banner')).toBeVisible()
68+
})
2569
})

src/api/index.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,51 @@ export async function rotateResource(id: string): Promise<{ ok: true; connection
393393
}
394394
}
395395

396+
// ─── Resource metrics ───
397+
//
398+
// GET /api/v1/resources/:id/metrics — per-resource time-series metrics
399+
// (p50/p95/p99 latency, active connections, storage_bytes, error_rate_pct)
400+
// over a tier-capped window. The dashboard polls this every 60s on the
401+
// Metrics tab; per CLAUDE.md feedback ("aggregations need caching +
402+
// consistency reasoning") the freshness model here is poll-on-demand, not
403+
// long-cached — these tiles are observability surfaces where seeing the
404+
// latest sample matters more than minimising server load.
405+
//
406+
// The response's `data_source` field is "stub" until the W5-A prober's
407+
// per-probe row writer lands. The dashboard renders a yellow "metrics will
408+
// populate" banner only when data_source === "stub" so the layout doesn't
409+
// shift when real data arrives.
410+
411+
export type MetricsDataSource = 'stub' | 'newrelic' | 'resource_metrics'
412+
413+
export interface ResourceMetricsResponse {
414+
ok: true
415+
resource_id: string
416+
resource_type: string
417+
window_seconds: number
418+
samples_count: number
419+
sample_interval_seconds: number
420+
metrics: {
421+
latency_p50_ms: number[]
422+
latency_p95_ms: number[]
423+
latency_p99_ms: number[]
424+
connections_active: number[]
425+
storage_bytes: number[]
426+
error_rate_pct: number[]
427+
}
428+
data_source: MetricsDataSource
429+
}
430+
431+
export async function getResourceMetrics(
432+
id: string,
433+
windowParam: string = '1h',
434+
): Promise<ResourceMetricsResponse> {
435+
const r = await call<ResourceMetricsResponse>(
436+
`/api/v1/resources/${id}/metrics?window=${encodeURIComponent(windowParam)}`,
437+
)
438+
return r
439+
}
440+
396441
// ─── Stacks / deployments ───
397442
// GET /api/v1/stacks returns one row per stack including the real env
398443
// (production / staging / dev / ...) and parent_stack_id linkage. We adapt
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// MetricsPanel.test.tsx — unit tests for the Metrics tab on
2+
// ResourceDetailPage. Three behaviours worth pinning:
3+
//
4+
// 1. Happy path: a non-stub response renders three charts + no banner.
5+
// 2. Stub mode: the yellow "metrics will populate" banner renders + the
6+
// data_source-stub branch does NOT swap layout (charts still mount).
7+
// 3. 402 upgrade-required: the upgrade prompt renders instead of charts.
8+
//
9+
// We mock `api.getResourceMetrics` directly because that's where the wire
10+
// shape lives — pulling in MSW or fetch-mock for one endpoint would add
11+
// more setup than the test surface justifies.
12+
13+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
14+
import { render, screen, cleanup, waitFor } from '@testing-library/react'
15+
import { MetricsPanel } from './MetricsPanel'
16+
import * as api from '../api'
17+
18+
afterEach(() => {
19+
cleanup()
20+
vi.restoreAllMocks()
21+
})
22+
23+
function makeFakeMetrics(overrides: Partial<api.ResourceMetricsResponse> = {}): api.ResourceMetricsResponse {
24+
// Deterministic synthetic series — sample values are fine because the
25+
// chart code only normalises against min/max.
26+
const sixty = Array.from({ length: 60 }, (_, i) => i + 1)
27+
return {
28+
ok: true,
29+
resource_id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
30+
resource_type: 'postgres',
31+
window_seconds: 3600,
32+
samples_count: 60,
33+
sample_interval_seconds: 60,
34+
metrics: {
35+
latency_p50_ms: sixty.map((v) => v * 0.05),
36+
latency_p95_ms: sixty.map((v) => v * 0.13),
37+
latency_p99_ms: sixty.map((v) => v * 0.30),
38+
connections_active: sixty.map((v) => 3 + (v % 3)),
39+
storage_bytes: sixty.map((v) => 1_000_000 + v * 50_000),
40+
error_rate_pct: sixty.map(() => 0),
41+
},
42+
data_source: 'newrelic',
43+
...overrides,
44+
}
45+
}
46+
47+
describe('MetricsPanel — happy path (real data)', () => {
48+
beforeEach(() => {
49+
vi.spyOn(api, 'getResourceMetrics').mockResolvedValue(makeFakeMetrics())
50+
})
51+
52+
it('renders all three charts after the fetch resolves', async () => {
53+
render(<MetricsPanel resourceId="aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" />)
54+
await waitFor(() => {
55+
expect(screen.getByTestId('metrics-panel')).toBeTruthy()
56+
})
57+
58+
// Latency chart + connections chart = 2 SVG line charts.
59+
// Storage tile is a number tile, not a chart.
60+
expect(screen.getAllByTestId('metrics-chart').length).toBe(2)
61+
expect(screen.getByTestId('metrics-storage-tile')).toBeTruthy()
62+
})
63+
64+
it('does NOT render the stub banner when data_source is not "stub"', async () => {
65+
render(<MetricsPanel resourceId="aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" />)
66+
await waitFor(() => {
67+
expect(screen.getByTestId('metrics-panel')).toBeTruthy()
68+
})
69+
expect(screen.queryByTestId('metrics-stub-banner')).toBeNull()
70+
})
71+
72+
it('renders three p50/p95/p99 series inside the latency chart', async () => {
73+
render(<MetricsPanel resourceId="aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" />)
74+
await waitFor(() => {
75+
expect(screen.getByTestId('metrics-panel')).toBeTruthy()
76+
})
77+
78+
const charts = screen.getAllByTestId('metrics-chart')
79+
const latencyChart = charts[0] // First Card is Latency
80+
// Each series is a <path data-series="..."/>. Three series → three paths.
81+
const seriesPaths = latencyChart.querySelectorAll('path[data-series]')
82+
expect(seriesPaths.length).toBe(3)
83+
expect(seriesPaths[0].getAttribute('data-series')).toBe('p50')
84+
expect(seriesPaths[1].getAttribute('data-series')).toBe('p95')
85+
expect(seriesPaths[2].getAttribute('data-series')).toBe('p99')
86+
})
87+
88+
it('storage tile renders a positive delta when storage grew across the window', async () => {
89+
render(<MetricsPanel resourceId="aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" />)
90+
await waitFor(() => {
91+
expect(screen.getByTestId('metrics-storage-delta')).toBeTruthy()
92+
})
93+
const delta = screen.getByTestId('metrics-storage-delta')
94+
// Synthetic data starts at 1_050_000 and ends at 4_000_000 (~). The sign
95+
// must be "+" because the series is strictly increasing.
96+
expect(delta.textContent).toContain('+')
97+
})
98+
})
99+
100+
describe('MetricsPanel — stub mode', () => {
101+
beforeEach(() => {
102+
vi.spyOn(api, 'getResourceMetrics').mockResolvedValue(
103+
makeFakeMetrics({ data_source: 'stub' }),
104+
)
105+
})
106+
107+
it('renders the yellow "metrics will populate" banner', async () => {
108+
render(<MetricsPanel resourceId="aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" />)
109+
await waitFor(() => {
110+
expect(screen.getByTestId('metrics-stub-banner')).toBeTruthy()
111+
})
112+
const banner = screen.getByTestId('metrics-stub-banner')
113+
expect(banner.textContent).toContain('Metrics will populate')
114+
expect(banner.textContent).toContain('5 minutes')
115+
})
116+
117+
it('still renders the charts (layout does not shift when stub flips off)', async () => {
118+
render(<MetricsPanel resourceId="aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" />)
119+
await waitFor(() => {
120+
expect(screen.getByTestId('metrics-panel')).toBeTruthy()
121+
})
122+
expect(screen.getAllByTestId('metrics-chart').length).toBe(2)
123+
expect(screen.getByTestId('metrics-storage-tile')).toBeTruthy()
124+
})
125+
})
126+
127+
describe('MetricsPanel — tier wall (402)', () => {
128+
beforeEach(() => {
129+
// Simulate the APIError shape thrown by the api/index.ts call() wrapper.
130+
// The wrapper sets `.status` and `.code` on Error before rejecting.
131+
const err = Object.assign(new Error('Resource metrics require the Pro plan or higher.'), {
132+
status: 402,
133+
code: 'upgrade_required',
134+
})
135+
vi.spyOn(api, 'getResourceMetrics').mockRejectedValue(err)
136+
})
137+
138+
it('renders the upgrade prompt instead of charts', async () => {
139+
render(<MetricsPanel resourceId="aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" />)
140+
await waitFor(() => {
141+
expect(screen.getByTestId('metrics-upgrade-required')).toBeTruthy()
142+
})
143+
// No chart elements rendered on the 402 path — calmer surface.
144+
expect(screen.queryAllByTestId('metrics-chart').length).toBe(0)
145+
expect(screen.queryByTestId('metrics-storage-tile')).toBeNull()
146+
147+
const card = screen.getByTestId('metrics-upgrade-required')
148+
expect(card.textContent).toContain('Pro')
149+
expect(card.textContent).toContain('Upgrade')
150+
})
151+
})
152+
153+
describe('MetricsPanel — generic error', () => {
154+
beforeEach(() => {
155+
const err = Object.assign(new Error('boom'), { status: 503, code: 'fetch_failed' })
156+
vi.spyOn(api, 'getResourceMetrics').mockRejectedValue(err)
157+
})
158+
159+
it('renders the error message in an alert region', async () => {
160+
render(<MetricsPanel resourceId="aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" />)
161+
await waitFor(() => {
162+
expect(screen.getByTestId('metrics-error')).toBeTruthy()
163+
})
164+
const err = screen.getByTestId('metrics-error')
165+
expect(err.textContent).toContain('boom')
166+
expect(err.getAttribute('role')).toBe('alert')
167+
})
168+
})

0 commit comments

Comments
 (0)