Skip to content

Commit c35db7e

Browse files
fix(dashboard): W11 honesty + pause/resume UI (P3 regressions) (#64)
* test(overview): pin sparkline-array regression at source-text level (W11) The fake sparkline arrays the P3 founder persona caught on 2026-05-13 ([22,20,18,...], [12,12,...], [18,15,8,16,...]) were removed earlier in W7-G's rewrite, and the existing DOM-level test already pins that no svg.sparkline elements render with the default no-series wiring. This commit hardens the pin by scanning OverviewPage.tsx source text for the three exact arrays after stripping comments — a tier-conditional regression that synthesises the series in only one code path would slip past a DOM-level smoke test but would still ship the literals. Comments are stripped first so the JSDoc reference to "[22,20,18,...]" inside Stat doesn't trip the assertion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dashboard): flip /audit gap→live + wire Audit tab to real endpoint (W11) ResourceDetailPage's API-contract panel still advertised GET /api/v1/resources/:id/metrics and /audit as status="gap" with a "request early access" mailto CTA. Metrics was actually live (W7-F, 2026-05-14) and the audit log is live at the team level (W7-C). The per-resource audit path doesn't exist server-side yet, but the team-level GET /api/v1/audit already scopes rows to team_id OR metadata.resource_id pointing at a resource the caller owns — so the dashboard fetches that window and filters client-side for matching resource_id. Precision cut, not a security boundary. Changes: - Flip the /audit ContractLine from status="gap" to status="live". - Drop the `audit-early-access` mailto CTA — there's no gap to apologise for now that the data is real. - Drop the legacy `blocked` tag from the Audit tab. - Wire the Audit tab body to a new <AuditPanel /> that fetches via api.fetchResourceAudit(resourceId, 24h) and renders an honest table: timestamp, actor_email_masked, kind, metadata. Empty state when no rows; upgrade-required state on 402 (anonymous/free tier); error banner on 5xx — never fake rows. - Add api.fetchResourceAudit() that wraps the team-level endpoint and applies the resource_id filter so the panel stays simple. - Stop using `r.token` as the AuditPanel resourceId — audit metadata stores the UUID (r.id), not the public token. - Fix a stray malformed `</>` closing the JSX root that the prior "AUDIT — blocked" placeholder left behind. Tests: - ResourceDetailPage.test.tsx — flip the .meta.gap assertion from 1 to 0, flip .meta.ok from 8 to 9, assert audit-early-access does NOT render, assert the Audit tab tag has no `.tag` span, and assert the AuditPanel mounts (empty / table / upgrade-required). - AuditPanel.test.tsx — five state pins: loading, empty, populated, 402 upgrade-required, generic 5xx error. npm test before: 473 pass, 3 skip / after: 519 pass, 3 skip (post-rebase baseline 522; +5 audit panel + +5 page-level audit tests + test reshuffle). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(resource-detail): pin PauseResumeButton presence on Overview tab (W11) P3 #58 regression: an earlier PR claimed pause/resume support but the component file didn't ship — grep on the time confirmed zero `pause|resume` references in dashboard/src/. The component eventually landed in W9-B1, and PauseResumeButton has its own dense test file, but those tests render the component in isolation and would still pass even if ResourceDetailPage forgot to mount it. This adds a page-level presence assertion: render ResourceDetailPage with an active resource and assert `pause-resume-button` is in the DOM. A future refactor that drops the import (or moves the button behind a tier-gated conditional that no longer fires) breaks the test, not the user. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aa9b3c7 commit c35db7e

6 files changed

Lines changed: 640 additions & 57 deletions

File tree

src/api/index.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,77 @@ export async function getResourceMetrics(
554554
return r
555555
}
556556

557+
// ─── Resource audit ─────────────────────────────────────────────────────
558+
//
559+
// W7-C/W11: the team-level audit log (GET /api/v1/audit) returns rows
560+
// scoped to either `team_id = caller_team` OR `metadata.resource_id`
561+
// pointing at a resource the caller owns. There is no per-resource
562+
// endpoint yet — instead we fetch the team window and filter client-side
563+
// for rows whose metadata.resource_id matches the resource we're looking
564+
// at. The endpoint enforces a tier-derived hard lookback floor (Hobby
565+
// 30d, Pro 90d, Team unlimited; anonymous/free 402s), so this surface
566+
// renders an "upgrade required" state on 402 rather than throwing.
567+
//
568+
// Wire shape (from auditEventToMap):
569+
// { id, kind, created_at, metadata, actor_user_id, actor_email_masked }
570+
//
571+
// `metadata` is unmarshalled JSON or null. We surface metadata.resource_id
572+
// and metadata.summary when present; everything else lands in the raw
573+
// metadata JSON column on the table for transparency.
574+
export interface ResourceAuditEvent {
575+
id: string
576+
kind: string
577+
created_at: string
578+
actor_user_id: string | null
579+
actor_email_masked: string | null
580+
metadata: Record<string, unknown> | null
581+
}
582+
583+
export interface ResourceAuditResponse {
584+
ok: true
585+
items: ResourceAuditEvent[]
586+
total_returned: number
587+
next_cursor: string | null
588+
lookback_days: number
589+
tier: string
590+
}
591+
592+
/**
593+
* Fetch audit rows scoped to a single resource over the last `sinceHours`
594+
* hours. Implementation: call GET /api/v1/audit?since=<iso>&limit=200 and
595+
* filter client-side for rows whose metadata.resource_id matches. The
596+
* team-level endpoint already enforces ownership (rows are returned only
597+
* if `team_id = caller_team` OR the metadata.resource_id points at a
598+
* resource the caller owns), so the client-side filter is a precision
599+
* cut, not a security boundary.
600+
*
601+
* On 402 (anonymous/free tier) the call propagates so the caller can
602+
* render an upgrade prompt instead of an error banner.
603+
*/
604+
export async function fetchResourceAudit(
605+
resourceId: string,
606+
sinceHours: number = 24,
607+
limit: number = 200,
608+
): Promise<ResourceAuditResponse> {
609+
const sinceIso = new Date(Date.now() - sinceHours * 60 * 60 * 1000).toISOString()
610+
const path = `/api/v1/audit?since=${encodeURIComponent(sinceIso)}&limit=${limit}`
611+
const r = await call<ResourceAuditResponse>(path)
612+
const items = (r.items ?? []).filter((ev) => {
613+
if (!ev.metadata || typeof ev.metadata !== 'object') return false
614+
const ridRaw = (ev.metadata as Record<string, unknown>).resource_id
615+
if (typeof ridRaw !== 'string') return false
616+
return ridRaw === resourceId
617+
})
618+
return {
619+
ok: true,
620+
items,
621+
total_returned: items.length,
622+
next_cursor: r.next_cursor ?? null,
623+
lookback_days: r.lookback_days ?? 0,
624+
tier: r.tier ?? '',
625+
}
626+
}
627+
557628
// ─── Stacks / deployments ───
558629
// GET /api/v1/stacks returns one row per stack including the real env
559630
// (production / staging / dev / ...) and parent_stack_id linkage. We adapt

src/components/AuditPanel.test.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/* AuditPanel.test.tsx — per-resource audit tab renderer.
2+
*
3+
* The panel calls api.fetchResourceAudit(resourceId, 24) on mount. The
4+
* underlying api helper hits GET /api/v1/audit?since=<24h-ago>&limit=200
5+
* and filters client-side for rows whose metadata.resource_id matches —
6+
* the panel doesn't re-filter, it trusts the helper.
7+
*
8+
* State pins:
9+
* - loading → renders <skel>
10+
* - ready + 0 rows → empty state with `audit-empty` testid
11+
* - ready + N rows → table with `audit-row-<id>` per row
12+
* - 402 → upgrade-required CTA
13+
* - other error → error banner
14+
*/
15+
16+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
17+
import { render, screen, waitFor, cleanup } from '@testing-library/react'
18+
import { AuditPanel } from './AuditPanel'
19+
20+
vi.mock('../api', async () => {
21+
const actual = await vi.importActual<typeof import('../api')>('../api')
22+
return {
23+
...actual,
24+
fetchResourceAudit: vi.fn(),
25+
}
26+
})
27+
import * as api from '../api'
28+
const mockFetch = api.fetchResourceAudit as unknown as ReturnType<typeof vi.fn>
29+
30+
beforeEach(() => mockFetch.mockReset())
31+
afterEach(() => cleanup())
32+
33+
describe('AuditPanel — load states', () => {
34+
it('shows the skeleton while the audit fetch is in flight', () => {
35+
mockFetch.mockReturnValueOnce(new Promise(() => { /* never resolves */ }))
36+
render(<AuditPanel resourceId="res_abc" />)
37+
expect(screen.getByTestId('audit-loading')).toBeTruthy()
38+
})
39+
40+
it('shows the empty state when the fetch resolves with zero events', async () => {
41+
mockFetch.mockResolvedValueOnce({
42+
ok: true,
43+
items: [],
44+
total_returned: 0,
45+
next_cursor: null,
46+
lookback_days: 90,
47+
tier: 'pro',
48+
})
49+
render(<AuditPanel resourceId="res_abc" />)
50+
await waitFor(() => expect(screen.getByTestId('audit-empty')).toBeTruthy())
51+
// Empty-state copy must NOT lie ("No audit events"). Sanity-check.
52+
expect(screen.getByTestId('audit-empty').textContent).toMatch(/no audit events/i)
53+
})
54+
55+
it('renders one row per event when the fetch resolves with data', async () => {
56+
mockFetch.mockResolvedValueOnce({
57+
ok: true,
58+
items: [
59+
{
60+
id: 'ev_1',
61+
kind: 'resource.rotate',
62+
created_at: '2026-05-14T10:00:00Z',
63+
actor_user_id: 'u1',
64+
actor_email_masked: 'm***@example.com',
65+
metadata: { resource_id: 'res_abc', source: 'agent' },
66+
},
67+
{
68+
id: 'ev_2',
69+
kind: 'resource.delete',
70+
created_at: '2026-05-14T11:00:00Z',
71+
actor_user_id: null,
72+
actor_email_masked: null,
73+
metadata: { resource_id: 'res_abc' },
74+
},
75+
],
76+
total_returned: 2,
77+
next_cursor: null,
78+
lookback_days: 90,
79+
tier: 'pro',
80+
})
81+
render(<AuditPanel resourceId="res_abc" />)
82+
await waitFor(() => expect(screen.getByTestId('audit-table')).toBeTruthy())
83+
expect(screen.getByTestId('audit-row-ev_1')).toBeTruthy()
84+
expect(screen.getByTestId('audit-row-ev_2')).toBeTruthy()
85+
expect(screen.getByTestId('audit-row-ev_1').textContent).toContain('resource.rotate')
86+
expect(screen.getByTestId('audit-row-ev_1').textContent).toContain('m***@example.com')
87+
// Rows with no actor render "system" rather than a blank cell.
88+
expect(screen.getByTestId('audit-row-ev_2').textContent).toContain('system')
89+
})
90+
91+
it('renders the upgrade-required CTA on 402', async () => {
92+
const err: any = new Error('Audit log export requires the Hobby plan or higher.')
93+
err.status = 402
94+
mockFetch.mockRejectedValueOnce(err)
95+
render(<AuditPanel resourceId="res_abc" />)
96+
await waitFor(() => expect(screen.getByTestId('audit-upgrade-required')).toBeTruthy())
97+
// The CTA must surface the pricing link so users have a path forward.
98+
const link = screen
99+
.getByTestId('audit-upgrade-required')
100+
.querySelector('a[href*="pricing"]') as HTMLAnchorElement
101+
expect(link).toBeTruthy()
102+
})
103+
104+
it('renders an error banner on a non-402 failure', async () => {
105+
const err: any = new Error('db_failed')
106+
err.status = 503
107+
mockFetch.mockRejectedValueOnce(err)
108+
render(<AuditPanel resourceId="res_abc" />)
109+
await waitFor(() => expect(screen.getByTestId('audit-error')).toBeTruthy())
110+
expect(screen.getByTestId('audit-error').textContent).toContain('db_failed')
111+
})
112+
})

0 commit comments

Comments
 (0)