From d1b6a0f1980a21d44cef3e3489c4fcfc9a62f235 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Sat, 20 Jun 2026 01:01:28 -0400 Subject: [PATCH 1/4] feat(hosts): host card chart icon links to latest scan report (Part A) Backend (api-hosts v1.6.0 C-13/AC-24): GET /hosts items now carry a nullable latest_scan_id = the newest COMPLETED scan_run id per host, loaded with one DISTINCT ON query (no N+1). Queued/running-only and never-scanned hosts resolve to null. Frontend (frontend-hosts-list v1.7.0 C-09/AC-22): the previously-inert chart icon on each host card + table row is now a ViewReportButton that links to /scans/{latestScanId}. Hidden when the host has no completed scan or the viewer lacks scan:read (the destination is scan:read-gated). --- api/openapi.yaml | 6 + frontend/src/api/host-view-model.ts | 7 + frontend/src/api/schema.d.ts | 2 + frontend/src/pages/HostsListPage.tsx | 34 +- frontend/tests/pages/hosts-list.test.ts | 24 + internal/server/api/server.gen.go | 716 ++++++++++--------- internal/server/api_hosts_enrichment_test.go | 79 ++ internal/server/hosts_enrichment.go | 35 + internal/server/hosts_handlers.go | 16 +- specs/api/hosts.spec.yaml | 10 +- specs/frontend/hosts-list.spec.yaml | 11 +- 11 files changed, 573 insertions(+), 367 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 03eb65c1..b6c1686c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4432,6 +4432,12 @@ components: # when no compliance check has ever run against the host. # Surface for the operator dashboard's "last scan X ago" cell. last_scan_at: {type: string, format: date-time, nullable: true} + # v1.6.0 — id of the newest COMPLETED scan_run for this host. Drives + # the host card's "view report" affordance, which links to + # /scans/{latest_scan_id} (the scan:read-gated detail page). NULL + # when the host has no completed scan yet (icon hidden). Spec + # api-hosts C-13. + latest_scan_id: {type: string, format: uuid, nullable: true} liveness: allOf: [{$ref: '#/components/schemas/HostLiveness'}] nullable: true diff --git a/frontend/src/api/host-view-model.ts b/frontend/src/api/host-view-model.ts index 903083eb..129a48c0 100644 --- a/frontend/src/api/host-view-model.ts +++ b/frontend/src/api/host-view-model.ts @@ -41,6 +41,13 @@ export interface DevHost { // renders "—" in that case rather than the misleading "0m ago". lastCheckMinutes: number | null; lastScan: string; // "Xh ago" or "Xm ago" + /** + * id of the newest completed scan_run, from the list endpoint's + * latest_scan_id. null when the host has no completed scan — the card's + * "view report" affordance is hidden in that case. Spec + * frontend-hosts-list AC-24, links to /scans/{latestScanId}. + */ + latestScanId: string | null; } export type DeltaTier = 'crit' | 'warn' | 'ok' | 'neutral'; diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 339bca14..e18bb6c8 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -2927,6 +2927,8 @@ export interface components { check_priority?: number; /** Format: date-time */ last_scan_at?: string | null; + /** Format: uuid */ + latest_scan_id?: string | null; /** @description Null when no liveness probe has ever run against this host. */ liveness?: components["schemas"]["HostLiveness"] | null; /** @description Null when the host has no host_rule_state rows (never scanned). */ diff --git a/frontend/src/pages/HostsListPage.tsx b/frontend/src/pages/HostsListPage.tsx index 314574fc..96aa3dd8 100644 --- a/frontend/src/pages/HostsListPage.tsx +++ b/frontend/src/pages/HostsListPage.tsx @@ -76,6 +76,8 @@ export interface ApiHost { check_priority?: number; /** v1.5.0 — MAX(host_rule_state.last_checked_at); null when never scanned. */ last_scan_at?: string | null; + /** v1.6.0 — id of the newest completed scan_run; null when none. Spec api-hosts C-13. */ + latest_scan_id?: string | null; liveness?: ApiHostLiveness | null; /** * v1.4.0 (api-hosts) — denormalized OS columns populated by @@ -897,6 +899,27 @@ function ScanHostButton({ hostId, variant }: { hostId: string; variant: 'card' | ); } +// ViewReportButton links the host card/row chart icon to the latest +// completed scan's detail (report) page, /scans/{latestScanId}. It +// renders nothing when the host has no completed scan (latestScanId null) +// or the viewer lacks scan:read — the destination is scan:read-gated, so +// showing a dead link would only 403. Spec frontend-hosts-list AC-24. +function ViewReportButton({ latestScanId }: { latestScanId: string | null }) { + const canRead = useAuthStore((s) => s.hasPermission('scan:read')); + if (!latestScanId || !canRead) return null; + return ( + + + + ); +} + function HostCard({ host }: { host: DevHost }) { const tier = complianceTier(host.compliance); const isDown = host.status === 'down'; @@ -1125,9 +1148,7 @@ function HostCard({ host }: { host: DevHost }) { Last scan {host.lastScan}
- +
@@ -1323,9 +1344,7 @@ function HostRow({ host }: { host: DevHost }) {
- +
@@ -1563,6 +1582,9 @@ export function apiHostToDev(h: ApiHost): DevHost { criticalFailing: cs?.critical_failing ?? 0, lastCheckMinutes, lastScan, + // v1.6.0: newest completed scan id for the "view report" link; null + // (icon hidden) when the host has no completed scan. Spec api-hosts C-13. + latestScanId: h.latest_scan_id ?? null, }; } diff --git a/frontend/tests/pages/hosts-list.test.ts b/frontend/tests/pages/hosts-list.test.ts index b736d5ac..163be1ed 100644 --- a/frontend/tests/pages/hosts-list.test.ts +++ b/frontend/tests/pages/hosts-list.test.ts @@ -64,6 +64,7 @@ function makeDevHost(overrides: Partial = {}): DevHost { total: 0, lastCheckMinutes: null, lastScan: '—', + latestScanId: null, ...overrides, }; } @@ -293,3 +294,26 @@ describe('frontend-hosts-list v1.6.0 — no demo/fixture data', () => { expect(PAGE_SRC).toMatch(/\(hostsQuery\.data \?\? \[\]\)\.map\(apiHostToDev\)/); }); }); + +describe('frontend-hosts-list v1.7.0 — view-report link', () => { + // @ac AC-22 + test('frontend-hosts-list/AC-22 — chart icon links to /scans/$scanId, gated on latestScanId + scan:read', () => { + const VM_SRC = readFileSync(resolve(process.cwd(), 'src/api/host-view-model.ts'), 'utf8'); + // DevHost carries latestScanId; apiHostToDev maps it from latest_scan_id. + expect(VM_SRC).toMatch(/latestScanId:\s*string \| null/); + expect(PAGE_SRC).toMatch(/latestScanId:\s*h\.latest_scan_id \?\? null/); + + // ViewReportButton: gated on scan:read AND a non-null latestScanId, + // returns null otherwise, and links to the scan-detail route. + expect(PAGE_SRC).toContain('function ViewReportButton'); + expect(PAGE_SRC).toMatch(/hasPermission\('scan:read'\)/); + expect(PAGE_SRC).toMatch(/if \(!latestScanId \|\| !canRead\) return null/); + expect(PAGE_SRC).toContain('to="/scans/$scanId"'); + expect(PAGE_SRC).toMatch(/params=\{\{ scanId: latestScanId \}\}/); + + // Used in BOTH the card footer and the table row (>= 2 mounts), and the + // old inert placeholder button is gone. + expect((PAGE_SRC.match(/