diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 803b0e6..970a1b9 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -11169,6 +11169,33 @@ function PageReplay({ onNavigate }) { // ---------------- Audit log ---------------- function PageAudit({ onNavigate }) { + // v1.7 slice 4e — wire to /api/audit (audit:read). The server + // route returns Event records as-stored — hash verification is the + // CLIENT'S job (AuditChainViewer recomputes hashes in the browser + // via Web Crypto). In mock mode the fixture fallback keeps the + // page usable when no server is reachable. + // PR #39 Copilot iter 6: re-check the cancellation guard AFTER + // await res.json() so an unmount mid-parse can't setState. + const [liveEvents, setLiveEvents] = React.useState(null); + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(apiUrl('/api/audit'), { + headers: { 'x-aqa-org': 'padosoft' }, + }); + if (cancelled || !res.ok) return; + const body = await res.json(); + if (cancelled) return; + if (Array.isArray(body?.events)) setLiveEvents(body.events); + } catch { + /* mock mode — leave the fixtures */ + } + })(); + return () => { + cancelled = true; + }; + }, []); return (
} - sub="Hash-chained, tamper-evident event log · verify in-browser with Web Crypto" + sub={ + // PR #39 Copilot iter 3: use the same "loaded" signal as + // the viewer below (`!== null`) — an empty server list + // is a valid loaded state, not a fixture-fallback. + liveEvents !== null + ? `${liveEvents.length} events · live from /api/audit` + : 'Hash-chained, tamper-evident event log · verify in-browser with Web Crypto' + } actions={ <> - @@ -11192,15 +11226,85 @@ function PageAudit({ onNavigate }) { } /> - + ({ + at: ev.ts ?? ev.at ?? '', + actor: + typeof ev.actor === 'string' + ? ev.actor + : ev.actor?.id || ev.actor?.type || 'system', + kind: ev.kind ?? 'event', + payload: ev.payload ?? {}, + prev_hash: ev.prev_hash ?? '0'.repeat(64), + hash: ev.hash ?? '0'.repeat(64), + })) + : AUDIT_EVENTS_GOOD + } + demoBad={liveEvents !== null ? [] : AUDIT_EVENTS_BAD} + />
); } // ---------------- Cost ---------------- function PageCost({ onNavigate }) { - const mtd = COST_DAYS.reduce((a, d) => a + d.usd, 0); - const dayCount = COST_DAYS.length; + // v1.7 slice 4e — wire to /api/cost/summary. The server default is + // a rolling 30-day window; the page's KPI labels say "MTD spend", + // so pass explicit `from`/`to` aligned to the current calendar + // month. PR #39 Copilot iter 1. + const [summary, setSummary] = React.useState(null); + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const now = new Date(); + const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const from = monthStart.toISOString(); + const to = now.toISOString(); + const url = `${apiUrl('/api/cost/summary')}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`; + // Tenant headers must match the admin's selected project so + // the server scopes correctly. The mock fixtures use + // `gescat`; a future tenant-switcher hook can swap this. + // PR #39 Copilot iter 2. + const res = await fetch(url, { + headers: { 'x-aqa-org': 'padosoft', 'x-aqa-project': 'gescat' }, + }); + if (cancelled || !res.ok) return; + const body = await res.json(); + if (body?.summary) setSummary(body.summary); + } catch { + /* mock mode */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + const mtd = summary?.total_usd ?? COST_DAYS.reduce((a, d) => a + d.usd, 0); + // PR #39 Copilot iter 6: when a live summary is loaded, derive + // dayCount from its [from,to] window so avgDay/projected/cum + // curves stay consistent with the server's requested range. + // Falls back to COST_DAYS.length in mock mode. + 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) { + const days = Math.max(1, Math.ceil((toMs - fromMs) / 86_400_000)); + return days; + } + } + return COST_DAYS.length; + })(); const avgDay = mtd / dayCount; const budget = 250; const projection = budget * 1.18; // 18% over by month-end @@ -11215,11 +11319,16 @@ function PageCost({ onNavigate }) { cum: (cum += d.usd), projected: false, })); - // Append projection + // Append projection. Anchor the date to the last entry of + // COST_DAYS (the mock fixture's last realized day) regardless of + // dayCount — when dayCount is derived from a live summary window + // it can be larger than COST_DAYS.length and indexing past the + // end would crash. PR #39 Copilot iter 6 (regression fix). const projDays = []; const remaining = daysInMonth - dayCount; + const lastDayDate = COST_DAYS[COST_DAYS.length - 1].date; for (let i = 1; i <= remaining; i++) { - const date = new Date(COST_DAYS[dayCount - 1].date); + const date = new Date(lastDayDate); date.setUTCDate(date.getUTCDate() + i); cum += avgDay; projDays.push({ date: date.toISOString().slice(0, 10), usd: avgDay, cum, projected: true }); @@ -11429,11 +11538,56 @@ function PageCost({ onNavigate }) { // ---------------- Queue ---------------- function PageQueue({ onNavigate }) { - const pending = QUEUE_JOBS.filter((j) => !j.leased_by).length; - const inflight = QUEUE_JOBS.filter((j) => j.leased_by).length; - const oldestPending = QUEUE_JOBS.find((j) => !j.leased_by); + // v1.7 slice 4e — wire to /api/queue. Server returns EnqueuedJob + // ({id, enqueued_at, payload, status, leased_until}) but the page + // renders fixture fields (kind, leased_by, attempts, stuck, + // payload_summary). Normalize the server payload to the UI shape + // so KPIs and the table render correctly when live. PR #39 Copilot + // iter 1. + 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)) return; + // PR #39 Copilot iter 3: filter out terminal `done` jobs + // before mapping — they'd otherwise be counted as "pending" + // (no leased_by) on the KPI grid. + const adapted = body.jobs + .filter((j) => j.status !== 'done') + .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) + : '', + })); + setJobs(adapted); + } 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); return (
@@ -11487,7 +11641,7 @@ function PageQueue({ onNavigate }) { Stuck jobs
- {QUEUE_JOBS.filter((j) => j.stuck).length} + {jobs.filter((j) => j.stuck).length}
{stuck ? (
@@ -11522,7 +11676,7 @@ function PageQueue({ onNavigate }) { - {QUEUE_JOBS.map((j) => ( + {jobs.map((j) => ( @@ -11690,23 +11844,78 @@ function PageQueue({ onNavigate }) { // ---------------- Notifications ---------------- function PageNotifications({ onNavigate }) { const [filter, setFilter] = React.useState('all'); - const kinds = [ - 'all', - 'finding.critical', + // v1.7 slice 4e — wire to /api/notifications. Server returns + // @aqa/schemas Notification ({id, kind, summary, actor, read_by, + // at, …}); UI expects fixture shape ({id, kind, title, body, + // unread, link, at}). Normalize so unread counts + rendering work + // against the live payload. PR #39 Copilot iter 1. SELF tracks + // 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); + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(apiUrl('/api/notifications'), { + headers: { 'x-aqa-org': 'padosoft' }, + }); + if (cancelled || !res.ok) return; + const body = await res.json(); + if (!Array.isArray(body?.notifications)) return; + const adapted = body.notifications.map((n) => ({ + id: n.id, + // Fallback to a schema-valid NotificationKind (the server + // always returns a valid kind, but stay defensive against + // malformed payloads). PR #39 Copilot iter 5. + kind: n.kind ?? 'run.failed', + // 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 ?? '', + unread: Array.isArray(n.read_by) ? !n.read_by.includes(SELF) : (n.unread ?? false), + link: n.link ?? null, + at: n.at ?? n.ts ?? new Date().toISOString(), + })); + setItems(adapted); + } catch { + /* mock mode */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + // PR #39 Copilot iter 4: filter base is the @aqa/schemas + // NotificationKind enum (the contract the server commits to). + // Then union with whatever's actually in items[] so any extras + // (legacy fixture rows, future enum additions) still show up. + // Previously this mixed fixture-only kinds with server kinds and + // presented filters that the server would never produce. + const SERVER_NOTIFICATION_KINDS = [ 'run.failed', - 'run.completed', + 'finding.critical', + 'finding.verified', 'budget.threshold', - 'pack.signed', - 'audit.verified', + 'audit.chain_broken', + 'pack.install_failed', + 'queue.stuck', + 'user.invited', ]; - const filtered = - filter === 'all' ? NOTIFICATIONS : NOTIFICATIONS.filter((n) => n.kind === filter); + const kinds = React.useMemo(() => { + const set = new Set(SERVER_NOTIFICATION_KINDS); + for (const n of items) { + if (typeof n.kind === 'string') set.add(n.kind); + } + return ['all', ...[...set].sort()]; + }, [items]); + const filtered = filter === 'all' ? items : items.filter((n) => n.kind === filter); return (
n.unread).length} unread of ${NOTIFICATIONS.length}`} + sub={`${items.filter((n) => n.unread).length} unread of ${items.length}`} actions={ <> ))} diff --git a/packages/admin/test/e2e/operations.e2e.ts b/packages/admin/test/e2e/operations.e2e.ts new file mode 100644 index 0000000..3a828fa --- /dev/null +++ b/packages/admin/test/e2e/operations.e2e.ts @@ -0,0 +1,187 @@ +import { expect, test } from '@playwright/test'; + +/** + * v1.7 slice 4e — admin Operations pages (Audit, Cost, Queue, + * Notifications) now read their data from the existing server + * endpoints when reachable, with a graceful fall-back to local + * fixtures so mock-data mode still renders. + * + * These tests assert the WIRING (request fires + page renders); + * full content assertions stay on the fixture-mode rendering + * tests in smoke.e2e.ts. + */ + +async function gotoNav(page: import('@playwright/test').Page, label: string): Promise { + await page.goto('/'); + await expect(page.locator('.sidebar')).toBeVisible(); + // Escape regex metacharacters in `label` so a nav item like + // "Audit (admin)" doesn't trip the parens. Mirrors smoke.e2e.ts. + const safe = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + await page + .locator('.nav-item', { hasText: new RegExp(`^${safe}`, 'i') }) + .first() + .click(); +} + +test.describe('Operations pages wire-up', () => { + test('Audit page fetches /api/audit with x-aqa-org and renders live count', async ({ page }) => { + // Hold the captured request in a stable wrapper object — TS strict + // flow-analysis narrows a `let` mutated inside a callback to its + // initial value at outer access sites, but object-property writes + // are opaque to it. + const seen: { url: string | null; org: string | null } = { url: null, org: null }; + await page.route('**/api/audit**', async (route) => { + const req = route.request(); + seen.url = req.url(); + seen.org = req.headers()['x-aqa-org'] ?? null; + await route.fulfill({ + status: 200, + contentType: 'application/json', + // Schema-conforming Event payload: seq, ts, actor:{type,id}, + // prev_hash, hash, run_id. + // PR #39 Copilot iter 4: schema-conforming Event — run_id at + // the top level, EventKind enum values (underscored, not + // dotted), prev_hash null on the chain head. + body: JSON.stringify({ + events: [ + { + schema_version: '1', + seq: 1, + ts: '2026-05-19T10:00:00Z', + run_id: 'run-1', + actor: { type: 'system', id: 'runner' }, + kind: 'run_started', + payload: {}, + prev_hash: null, + hash: 'a'.repeat(64), + }, + { + schema_version: '1', + seq: 2, + ts: '2026-05-19T10:05:00Z', + run_id: 'run-1', + actor: { type: 'system', id: 'runner' }, + kind: 'run_finished', + payload: {}, + prev_hash: 'a'.repeat(64), + hash: 'b'.repeat(64), + }, + ], + }), + }); + }); + await gotoNav(page, 'Audit log'); + await expect(page.locator('h1, .page-title').first()).toContainText(/Audit log/i); + await expect(page.locator('text=2 events · live from /api/audit')).toBeVisible(); + expect(seen.url).toMatch(/\/api\/audit(\?|$)/); + expect(seen.org).toBe('padosoft'); + }); + + test('Audit falls back to the fixture when the endpoint fails', async ({ page }) => { + await page.route('**/api/audit**', (route) => route.abort('failed')); + await gotoNav(page, 'Audit log'); + // Fixture-mode sub-header (no "live from" claim). + await expect(page.locator('text=Hash-chained, tamper-evident')).toBeVisible(); + }); + + test('Queue page fetches /api/queue and renders the live jobs', async ({ page }) => { + let hit = false; + await page.route('**/api/queue**', async (route) => { + hit = true; + // PR #39 Copilot iter 2: use the SERVER's EnqueuedJob shape + // ({status, leased_until, payload}) so the test actually + // exercises PageQueue's adapter logic. + await route.fulfill({ + status: 200, + contentType: 'application/json', + // PR #39 Copilot iter 4: queued jobs omit `leased_until` + // (it's only set when the job is leased to a runner). + body: JSON.stringify({ + jobs: [ + { + id: 'job-live-1', + enqueued_at: '2026-05-19T11:00:00Z', + payload: { kind: 'aqa.run', profile: 'smoke' }, + status: 'queued', + }, + ], + }), + }); + }); + 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 + // the full string matches the table's mono cell. + await expect(page.locator('table.tbl', { hasText: 'job-live-1' })).toBeVisible(); + }); + + test('Notifications page fetches /api/notifications with x-aqa-org', async ({ page }) => { + const seen: { org: string | null } = { org: null }; + await page.route('**/api/notifications**', async (route) => { + seen.org = route.request().headers()['x-aqa-org'] ?? null; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notifications: [] }), + }); + }); + await gotoNav(page, 'Notifications'); + await expect(page.locator('h1, .page-title').first()).toContainText(/Notifications/i); + expect(seen.org).toBe('padosoft'); + }); + + test('Cost page fetches /api/cost/summary with tenant scope + explicit MTD bounds', async ({ + page, + }) => { + // PR #39 Copilot iter 1: the request must carry explicit `from`/ + // `to` aligned to the current month so the server's total_usd + // matches the "MTD spend" KPI label (default window is rolling + // 30 days, which spans month boundaries). + const seen: { + org: string | null; + project: string | null; + from: string | null; + to: string | null; + } = { org: null, project: null, from: null, to: null }; + await page.route('**/api/cost/summary**', async (route) => { + const url = new URL(route.request().url()); + seen.org = route.request().headers()['x-aqa-org'] ?? null; + seen.project = route.request().headers()['x-aqa-project'] ?? null; + seen.from = url.searchParams.get('from'); + seen.to = url.searchParams.get('to'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + // PR #39 Copilot iter 2: schema-conforming CostSummary + // (schema_version + tenant scope echoed back). + // PR #39 Copilot iter 4: aligned to @aqa/schemas CostSummary + // exactly — no extra `currency` field (the schema doesn't + // define it). `daily` is the date→usd record used by the + // chart. + body: JSON.stringify({ + summary: { + schema_version: '1', + org: 'padosoft', + project: 'gescat', + from: '2026-05-01T00:00:00.000Z', + to: '2026-05-19T23:00:00.000Z', + total_usd: 123.45, + by_profile: [], + daily: {}, + }, + }), + }); + }); + await gotoNav(page, 'Cost'); + await expect(page.locator('h1, .page-title').first()).toContainText(/Cost/i); + expect(seen.org).toBe('padosoft'); + // PR #39 Copilot iter 2: project is now `gescat` (matches the + // admin's selected project). + expect(seen.project).toBe('gescat'); + // `from` is the first of the current month at 00:00 UTC. + expect(seen.from).toMatch(/^\d{4}-\d{2}-01T00:00:00\.000Z$/); + expect(seen.to).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); +});