feat(slice-4e): wire admin Audit/Cost/Queue/Notifications to existing endpoints#39
Conversation
… endpoints User asked for slice 4e to audit each Operations admin page against the server route surface and wire any missing reads. The endpoints (/api/audit, /api/cost/summary, /api/queue, /api/notifications) all existed already; this slice adds the client-side fetches with graceful fallback to the local fixtures so mock-data mode keeps working. Admin: - PageAudit fetches /api/audit (x-aqa-org). When the server responds, the page subtitle reads "N events · live from /api/audit" and the AuditChainViewer renders the live events (the fixture demoBad is suppressed in live mode to avoid mixing concerns). - PageQueue fetches /api/queue and replaces QUEUE_JOBS with the live payload (KPIs + table both refresh). - PageCost fetches /api/cost/summary with tenant headers (x-aqa-org, x-aqa-project) and uses the server's total_usd as the MTD figure when available. - PageNotifications fetches /api/notifications (x-aqa-org). E2E: 5 new tests in operations.e2e.ts asserting the request fires, the headers are set, and either the live count renders (audit) or the fixture sub-header survives (audit fallback). Tests: admin e2e 128/129 (+5, 1 skipped). Lint preexisting only.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4133fc3d30
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const res = await fetch(apiUrl('/api/queue')); | ||
| if (cancelled || !res.ok) return; | ||
| const body = await res.json(); | ||
| if (Array.isArray(body?.jobs)) setJobs(body.jobs); |
There was a problem hiding this comment.
Normalize queue API jobs before using queue KPIs
This assigns body.jobs directly to jobs, but GET /api/queue returns ctx.queue.snapshot() entries (EnqueuedJob in packages/server/src/runner-queue.ts) that expose status/leased_until/payload, not the UI fields used here (leased_by, attempts, payload_summary, stuck). In live mode that makes queue metrics and table cells misleading (for example, in-flight jobs are counted as pending because leased_by is undefined). Please map the API payload to the UI contract (or update the UI logic to the queue schema) before calling setJobs.
Useful? React with 👍 / 👎.
| }); | ||
| if (cancelled || !res.ok) return; | ||
| const body = await res.json(); | ||
| if (Array.isArray(body?.notifications)) setItems(body.notifications); |
There was a problem hiding this comment.
Adapt notifications API payload to the UI notification shape
The code stores body.notifications as-is, but the server notification schema (packages/schemas/src/notification.ts) uses fields like summary and read_by, while this page renders title, body, and unread plus fixture-specific kinds. With real API data, rows render incomplete content and unread/filter behavior becomes incorrect. Add a mapping layer from API notifications to the UI model (including derived unread state) before setting state.
Useful? React with 👍 / 👎.
| const res = await fetch(apiUrl('/api/cost/summary'), { | ||
| headers: { 'x-aqa-org': 'padosoft', 'x-aqa-project': 'demo' }, | ||
| }); |
There was a problem hiding this comment.
Request an explicit month window for MTD cost
This request omits from/to, but GET /api/cost/summary defaults to a rolling 30-day window in packages/server/src/api.ts, while the page labels the KPI as “MTD spend”. Around month boundaries this pulls prior-month cost into the MTD number and misstates budget usage. Pass an explicit month-start to now window (or change the label/logic) before using summary.total_usd as MTD.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR updates the admin “Operations” screens (Audit log, Cost, Queue, Notifications) to attempt to load live data from existing server endpoints, while keeping the existing fixture-based UI as a fallback. It also adds Playwright E2E coverage to assert the wiring (requests fire, headers are sent, and basic live/fallback rendering works).
Changes:
- Added
operations.e2e.tsPlaywright suite to verify requests, tenant headers, and an audit fallback path. - Wired
PageAudit,PageCost,PageQueue, andPageNotificationsto call/api/audit,/api/cost/summary,/api/queue, and/api/notifications. - Updated the Audit header subtitle to reflect “live from /api/audit” and added a
data-testidto the Audit download button.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| packages/admin/src/app.tsx | Adds live fetches for Audit/Cost/Queue/Notifications and switches UI state to server payloads when available. |
| packages/admin/test/e2e/operations.e2e.ts | Adds E2E tests asserting endpoint wiring, header propagation, and fixture fallback for audit. |
| const res = await fetch(apiUrl('/api/audit'), { | ||
| headers: { 'x-aqa-org': 'padosoft' }, | ||
| }); | ||
| if (cancelled || !res.ok) return; | ||
| const body = await res.json(); | ||
| if (Array.isArray(body?.events)) setLiveEvents(body.events); | ||
| } catch { |
| demoGood={liveEvents && liveEvents.length > 0 ? liveEvents : AUDIT_EVENTS_GOOD} | ||
| demoBad={liveEvents ? [] : AUDIT_EVENTS_BAD} |
| const [jobs, setJobs] = React.useState(QUEUE_JOBS); | ||
| React.useEffect(() => { | ||
| let cancelled = false; | ||
| (async () => { | ||
| try { | ||
| const res = await fetch(apiUrl('/api/queue')); | ||
| if (cancelled || !res.ok) return; | ||
| const body = await res.json(); | ||
| if (Array.isArray(body?.jobs)) setJobs(body.jobs); | ||
| } catch { | ||
| /* mock mode — keep the fixture */ | ||
| } | ||
| })(); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, []); | ||
| const pending = jobs.filter((j) => !j.leased_by).length; | ||
| const inflight = jobs.filter((j) => j.leased_by).length; | ||
| const oldestPending = jobs.find((j) => !j.leased_by); | ||
| const onlineRunners = RUNNERS.filter((r) => r.online).length; | ||
| const stuck = QUEUE_JOBS.find((j) => j.stuck); | ||
| const stuck = jobs.find((j) => j.stuck); |
| @@ -11699,8 +11799,7 @@ function PageNotifications({ onNavigate }) { | |||
| 'pack.signed', | |||
| 'audit.verified', | |||
| ]; | |||
| const filtered = | |||
| filter === 'all' ? NOTIFICATIONS : NOTIFICATIONS.filter((n) => n.kind === filter); | |||
| const filtered = filter === 'all' ? items : items.filter((n) => n.kind === filter); | |||
|
|
|||
| async function gotoNav(page: import('@playwright/test').Page, label: string): Promise<void> { | ||
| await page.goto('/'); | ||
| await expect(page.locator('.sidebar')).toBeVisible(); | ||
| await page | ||
| .locator('.nav-item', { hasText: new RegExp(`^${label}`, 'i') }) |
| const res = await fetch(apiUrl('/api/cost/summary'), { | ||
| headers: { 'x-aqa-org': 'padosoft', 'x-aqa-project': 'demo' }, | ||
| }); | ||
| if (cancelled || !res.ok) return; | ||
| const body = await res.json(); | ||
| if (body?.summary) setSummary(body.summary); |
…icit MTD bounds
Copilot/Codex iter-1 (5 P1+P2):
- AUDIT: server @aqa/schemas Event uses { ts, actor: { type, id },
prev_hash }, but AuditChainViewer expects { at, actor: string,
prev_hash }. Added a normalizer in the live-events branch so
the viewer's actor.toLowerCase() / ev.at reads don't break.
Also switched the "loaded" check from `liveEvents.length > 0`
to `liveEvents !== null` so an empty live response is no longer
silently masked by the fixture (while the header still claims
"live").
- QUEUE: server EnqueuedJob is { id, enqueued_at, payload, status,
leased_until }; UI expects { kind, leased_by, attempts, stuck,
payload_summary }. Added a mapper: status === 'in_flight' →
leased_by; expired leased_until → stuck; payload.kind → kind;
truncated JSON.stringify(payload) → payload_summary.
- NOTIFICATIONS: server Notification uses { summary, actor,
read_by, … }; UI expects { title, body, unread }. Adapter
derives `title` from summary (or a humanized kind), `unread`
from `!read_by.includes(SELF)`, etc.
- COST: server defaults to a rolling 30-day window but the page
KPI reads "MTD spend"; pass explicit from/to aligned to the
current calendar month so the total_usd matches the label.
- Test gotoNav now escapes regex metacharacters in `label` to
match smoke.e2e.ts (a nav like "Audit (admin)" would otherwise
trip parens).
- Cost e2e now asserts the request carries `from` (1st of current
month) and `to` query params.
Tests: operations e2e 5/5 green; admin e2e 128/129 (1 skipped);
lint preexisting only.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.
Comments suppressed due to low confidence (1)
packages/admin/src/app.tsx:11290
- When
summary?.total_usdis used as MTD spend,dayCountstill comes fromCOST_DAYS.length(fixture has 18 days). This makesAvg dailyandProjected · month-endincorrect in live mode (they’ll be computed using the fixture day count rather than the livefrom/towindow). DerivedayCount(and ideallydaysInMonth) from the live time range (e.g.,to/fromor current UTC date) whensummaryis present.
const mtd = summary?.total_usd ?? COST_DAYS.reduce((a, d) => a + d.usd, 0);
const dayCount = COST_DAYS.length;
const avgDay = mtd / dayCount;
const budget = 250;
const projection = budget * 1.18; // 18% over by month-end
const daysInMonth = 31;
const projected = (mtd / dayCount) * daysInMonth;
| const to = now.toISOString(); | ||
| const url = `${apiUrl('/api/cost/summary')}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`; | ||
| const res = await fetch(url, { | ||
| headers: { 'x-aqa-org': 'padosoft', 'x-aqa-project': 'demo' }, |
| const SELF = 'usr_self'; | ||
| const [items, setItems] = React.useState(NOTIFICATIONS); | ||
| React.useEffect(() => { | ||
| let cancelled = false; | ||
| (async () => { |
| @@ -11699,8 +11857,7 @@ function PageNotifications({ onNavigate }) { | |||
| 'pack.signed', | |||
| 'audit.verified', | |||
| ]; | |||
| await page.route('**/api/audit', async (route) => { | ||
| const req = route.request(); | ||
| captured = { url: req.url(), org: req.headers()['x-aqa-org'] ?? null }; |
| { schema_version: '1', ts: '2026-05-19T10:00:00Z', kind: 'run.started', payload: {} }, | ||
| { schema_version: '1', ts: '2026-05-19T10:05:00Z', kind: 'run.finished', payload: {} }, |
| await route.fulfill({ | ||
| status: 200, | ||
| contentType: 'application/json', | ||
| body: JSON.stringify({ | ||
| jobs: [ | ||
| { | ||
| id: 'job-live-1', | ||
| kind: 'aqa.run', | ||
| enqueued_at: '2026-05-19T11:00:00Z', | ||
| leased_by: null, | ||
| stuck: false, | ||
| }, | ||
| ], | ||
| }), | ||
| }); |
| await route.fulfill({ | ||
| status: 200, | ||
| contentType: 'application/json', | ||
| body: JSON.stringify({ summary: { total_usd: 123.45, by_profile: [], by_model: [] } }), |
| await gotoNav(page, 'Cost'); | ||
| await expect(page.locator('h1, .page-title').first()).toContainText(/Cost/i); | ||
| expect(captured?.org).toBe('padosoft'); | ||
| expect(captured?.project).toBe('demo'); |
…ue test mocks, dynamic kinds
Copilot iter-2 (8):
- Cost summary header had `x-aqa-project: demo` hardcoded but the
admin selected project is `gescat` (PROJECTS list / TopBar
switcher). Fixed.
- Notifications unread check compared `read_by` against a hardcoded
'usr_self' that doesn't exist. Switched to SESSION_USER.id
(= 'usr_sara') so the unread state resolves correctly against
live payloads.
- Notifications filter `kinds` list omitted server kinds
(audit.chain_broken, pack.install_failed, queue.stuck,
user.invited, …). Derive the union dynamically from a static
base + whatever's in items[] so live payloads stay filterable.
- E2E route patterns: `**/api/audit**` (was `**/api/audit`) and
`**/api/queue**` so the matchers stay resilient to query params.
- Audit/Queue/Cost mock responses rewritten to the SERVER schema
shapes (Event with seq/actor:{type,id}/prev_hash; EnqueuedJob
with status/leased_until/payload; CostSummary with
schema_version/org/project/from/to). Tests now exercise the
PageAudit/PageQueue/PageCost adapters end-to-end.
- Cost test assertion now expects project=gescat (matches the
fixed header).
Tests: operations e2e 5/5 green.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
packages/admin/src/app.tsx:11295
- When
summary?.total_usdis present,mtdbecomes live data butdayCountand all projection inputs still come from the May-2026 fixtures (COST_DAYS). This makes “Avg daily”, “Projected”, and the cumulative chart internally inconsistent (fixture curve won’t sum to live MTD). Consider derivingdayCount/projection fromsummary.from→summary.to, or keeping KPI/projection fully fixture-based unless you also fetch per-day data.
const mtd = summary?.total_usd ?? COST_DAYS.reduce((a, d) => a + d.usd, 0);
const dayCount = COST_DAYS.length;
const avgDay = mtd / dayCount;
const budget = 250;
const projection = budget * 1.18; // 18% over by month-end
const daysInMonth = 31;
const projected = (mtd / dayCount) * daysInMonth;
| } | ||
| sub="Hash-chained, tamper-evident event log · verify in-browser with Web Crypto" | ||
| sub={ | ||
| liveEvents |
| const adapted = body.jobs.map((j) => ({ | ||
| id: j.id, | ||
| kind: (j.payload && typeof j.payload === 'object' ? j.payload.kind : null) ?? 'aqa.run', | ||
| enqueued_at: j.enqueued_at ?? new Date().toISOString(), | ||
| leased_by: j.status === 'in_flight' ? (j.leased_by ?? 'runner') : null, | ||
| attempts: j.attempts ?? 0, | ||
| stuck: | ||
| typeof j.leased_until === 'string' | ||
| ? new Date(j.leased_until).getTime() < Date.now() | ||
| : false, | ||
| payload_summary: | ||
| j.payload && typeof j.payload === 'object' | ||
| ? JSON.stringify(j.payload).slice(0, 80) | ||
| : '', | ||
| })); |
| await gotoNav(page, 'Queue'); | ||
| await expect(page.locator('h1, .page-title').first()).toContainText(/Queue/i); | ||
| expect(hit).toBe(true); | ||
| // Live job id appears in the table (last 12 chars). |
… accuracy Copilot iter-3 (3 new + many stale repeats): - Audit subtitle now uses `liveEvents !== null` (matches the viewer's loaded signal). The previous truthy check `liveEvents ? …` happened to work for [] (arrays are truthy in JS), but the explicit form is clearer and aligns with the viewer's branch. - Queue adapter filters out terminal `done` jobs before mapping to the UI shape; otherwise those would land in the pending KPI (status='done' but leased_by=null after the map). - Test comment "(last 12 chars)" was misleading because `job-live-1` is shorter than 12 chars; clarified that the full id matches in this case. (Codex/Copilot also re-emitted ~7 already-addressed comments from iter 1/2; all noted line:null and already fixed.) Tests: operations e2e 5/5 green.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
packages/admin/src/app.tsx:11297
- When live
summary.total_usdis used for MTD,dayCountis still derived from the fixtureCOST_DAYS.length. That makesavgDay,projected, and the “days observed” label incorrect in live mode (and can also skew projections). Consider derivingdayCountfrom the livesummary.from/summary.to(or fromsummary.dailyif present) whensummaryis loaded.
const mtd = summary?.total_usd ?? COST_DAYS.reduce((a, d) => a + d.usd, 0);
const dayCount = COST_DAYS.length;
const avgDay = mtd / dayCount;
const budget = 250;
const projection = budget * 1.18; // 18% over by month-end
const daysInMonth = 31;
const projected = (mtd / dayCount) * daysInMonth;
| // Schema-conforming Event payload: seq, ts, actor:{type,id}, | ||
| // prev_hash, hash, run_id. | ||
| body: JSON.stringify({ | ||
| events: [ | ||
| { | ||
| schema_version: '1', | ||
| seq: 1, | ||
| ts: '2026-05-19T10:00:00Z', | ||
| actor: { type: 'system', id: 'runner' }, | ||
| kind: 'run.started', | ||
| payload: { run_id: 'r-1' }, | ||
| prev_hash: '0'.repeat(64), | ||
| hash: 'a'.repeat(64), | ||
| }, |
| enqueued_at: '2026-05-19T11:00:00Z', | ||
| payload: { kind: 'aqa.run', profile: 'smoke' }, | ||
| status: 'queued', | ||
| leased_until: null, |
| to: '2026-05-19T23:00:00.000Z', | ||
| total_usd: 123.45, | ||
| by_profile: [], | ||
| currency: 'USD', |
| const kinds = React.useMemo(() => { | ||
| const set = new Set([ | ||
| 'finding.critical', | ||
| 'run.failed', | ||
| 'run.completed', | ||
| 'budget.threshold', | ||
| 'pack.signed', | ||
| 'audit.verified', | ||
| 'audit.chain_broken', | ||
| 'pack.install_failed', | ||
| 'queue.stuck', | ||
| 'user.invited', | ||
| ]); |
…ver enum Copilot iter-4 (4): - Audit test mock now matches @aqa/schemas Event exactly: run_id at top level, EventKind enum values (run_started / run_finished, not dotted), prev_hash null on the chain head. - Queue mock: queued jobs omit `leased_until` (it's only set when leased to a runner), matching the server's EnqueuedJob shape. - Cost mock dropped the non-schema `currency` field; added the required `daily` record (date → usd, empty here). - Notifications kinds filter list rebased on the @aqa/schemas NotificationKind enum (the server contract); still unioned with items[] so live extras still surface as filter options. Previously mixed fixture-only kinds with server kinds and presented filters the server could never produce. Tests: operations e2e 5/5 green.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
packages/admin/src/app.tsx:11297
- When a live
summaryis loaded, onlymtdswitches tosummary.total_usdwhiledayCount, the cumulative/projection series (COST_DAYS), and per-profile/table sections remain based on fixtures. This makes KPIs like “Avg daily / days observed” and the chart inconsistent with the live MTD value. Consider derivingdayCount+ series fromsummary.daily(and usingsummary.by_profile) whensummaryis present, or keep all calculations on fixtures until the full page is wired.
const mtd = summary?.total_usd ?? COST_DAYS.reduce((a, d) => a + d.usd, 0);
const dayCount = COST_DAYS.length;
const avgDay = mtd / dayCount;
const budget = 250;
const projection = budget * 1.18; // 18% over by month-end
const daysInMonth = 31;
const projected = (mtd / dayCount) * daysInMonth;
| const adapted = body.notifications.map((n) => ({ | ||
| id: n.id, | ||
| kind: n.kind ?? 'audit.verified', | ||
| // Title falls back to a humanized kind so we always render | ||
| // something even when the server omits `summary`. | ||
| title: n.summary ?? n.title ?? (n.kind ?? 'event').replace(/\./g, ' ').toUpperCase(), | ||
| body: n.body ?? '', |
| // the actual session user so read_by membership checks resolve | ||
| // correctly (iter 2 — previously was a hardcoded `usr_self`). | ||
| const SELF = SESSION_USER.id; | ||
| const [items, setItems] = React.useState(NOTIFICATIONS); |
Copilot iter-5 (2):
- Notification adapter fallback `kind ?? 'audit.verified'` used a
non-schema kind ('audit.verified' isn't in NotificationKind).
Switched the (defensive-only) fallback to 'run.failed' so a
malformed payload still satisfies the schema-aligned filter list.
- Header subtitle ("N unread of M") and per-kind chip counts still
read from the static NOTIFICATIONS fixture, so the counts
disagreed with the rendered list when the server replaced items.
Sourced both from `items` instead.
Tests: operations e2e 5/5 green.
| // returns chain-verified events; in mock mode we fall back to the | ||
| // hardcoded fixtures (AUDIT_EVENTS_GOOD / _BAD) so the demo stays | ||
| // usable when no server is reachable. The AuditChainViewer's |
| headers: { 'x-aqa-org': 'padosoft' }, | ||
| }); | ||
| if (cancelled || !res.ok) return; | ||
| const body = await res.json(); |
| <AuditChainViewer | ||
| // PR #39 Copilot iter 1: server Event has {ts, actor:{type,id}, | ||
| // prev_hash} but AuditChainViewer's demo shape is {at, actor: | ||
| // string, prev_hash}. Normalize the server response before | ||
| // handing it to the viewer; otherwise the viewer breaks on |
| const mtd = summary?.total_usd ?? COST_DAYS.reduce((a, d) => a + d.usd, 0); | ||
| const dayCount = COST_DAYS.length; | ||
| const avgDay = mtd / dayCount; | ||
| const budget = 250; |
| headers: { 'x-aqa-org': 'padosoft', 'x-aqa-project': 'gescat' }, | ||
| }); | ||
| if (cancelled || !res.ok) return; | ||
| const body = await res.json(); |
| const res = await fetch(apiUrl('/api/queue')); | ||
| if (cancelled || !res.ok) return; | ||
| const body = await res.json(); | ||
| if (!Array.isArray(body?.jobs)) return; |
| leased_by: j.status === 'in_flight' ? (j.leased_by ?? 'runner') : null, | ||
| attempts: j.attempts ?? 0, |
| if (cancelled || !res.ok) return; | ||
| const body = await res.json(); | ||
| if (!Array.isArray(body?.notifications)) return; |
…er window Copilot iter-6 (subset of 4 actionable): - AUDIT fetch re-checks `cancelled` after `await res.json()` so an unmount mid-parse can't trigger setState on a dead component. - Comment on the audit wire reworded: the server returns events as stored; AuditChainViewer is the one that verifies hashes client-side via Web Crypto. (The previous "chain-verified events" wording implied server-side verification.) - COST: dayCount is now derived from summary.from/to when a live summary is loaded, so avgDay/projected/cumulative curves stay consistent with the server-requested [from,to] window. Falls back to COST_DAYS.length in mock mode. (The 4th comment about AuditChainViewer's auto-load behavior needs a viewer-API change that's outside this slice; left for follow-up.) Tests: operations e2e 5/5 green.
…ot dayCount When dayCount is derived from a live summary window it can be > COST_DAYS.length, and COST_DAYS[dayCount-1] would be undefined. Anchor to the fixture's last realized day instead. Regression from iter 6. Tests: operations e2e 5/5 green.
| const dayCount = (() => { | ||
| if (summary?.from && summary?.to) { | ||
| const fromMs = new Date(summary.from).getTime(); | ||
| const toMs = new Date(summary.to).getTime(); | ||
| if (Number.isFinite(fromMs) && Number.isFinite(toMs) && toMs >= fromMs) { |
| headers: { 'x-aqa-org': 'padosoft', 'x-aqa-project': 'gescat' }, | ||
| }); | ||
| if (cancelled || !res.ok) return; | ||
| const body = await res.json(); |
| }); | ||
| await gotoNav(page, 'Notifications'); | ||
| await expect(page.locator('h1, .page-title').first()).toContainText(/Notifications/i); | ||
| expect(captured?.org).toBe('padosoft'); |
| await gotoNav(page, 'Queue'); | ||
| await expect(page.locator('h1, .page-title').first()).toContainText(/Queue/i); | ||
| expect(hit).toBe(true); | ||
| // Live job id appears in the table. The UI renders only the last | ||
| // 12 chars in the id column, but `job-live-1` is already <12 so |
| await gotoNav(page, 'Cost'); | ||
| await expect(page.locator('h1, .page-title').first()).toContainText(/Cost/i); | ||
| expect(captured?.org).toBe('padosoft'); | ||
| // PR #39 Copilot iter 2: project is now `gescat` (matches the | ||
| // admin's selected project). |
| const [jobs, setJobs] = React.useState(QUEUE_JOBS); | ||
| React.useEffect(() => { | ||
| let cancelled = false; | ||
| (async () => { | ||
| try { |
| const [items, setItems] = React.useState(NOTIFICATIONS); | ||
| React.useEffect(() => { | ||
| let cancelled = false; | ||
| (async () => { | ||
| try { |
… strict) The CI typecheck (stricter than local) flagged as never because the assignment happens inside the page.route callback closure, and TS flow analysis narrows the outer let back to null at access sites. Switched all four tests to a stable wrapper and mutate properties — TS treats object-property writes through closures as opaque. Tests: operations e2e 5/5 green; typecheck clean.
Summary
v1.7 slice 4e — Operations admin pages (Audit/Cost/Queue/Notifications) now read from the existing server endpoints when reachable, with a graceful fallback to local fixtures.
Admin wires
E2E
Test plan
🤖 Generated with Claude Code