diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index b3a1e1ff..cab3796c 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useParams, useSearch, useNavigate, Link } from '@tanstack/react-router'; import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'; import { @@ -182,7 +182,7 @@ const TAB_ORDER: { id: TabId; label: string; icon: LucideIcon }[] = [ // Backend subsystem that populates each tab when it lands. Surfaces // inside the per-tab empty state so operators know what's deferred. const TAB_BACKEND_SUBSYSTEM: Record< - Exclude, + Exclude, string > = { packages: 'Server Intelligence collection — installed-package inventory deferred (BACKLOG).', @@ -191,7 +191,6 @@ const TAB_BACKEND_SUBSYSTEM: Record< network: 'Server Intelligence collection — interfaces and firewall rules deferred (BACKLOG).', audit_log: 'Audit query API — host-scoped audit feed deferred to the unified /activity page (BACKLOG).', - activity: 'Unified Activity feed — combined transactions + audits + alerts deferred (BACKLOG).', terminal: 'Web terminal — SSH-in-browser deferred; use a host-side SSH client in the meantime.', }; @@ -487,6 +486,8 @@ export function HostDetailPage() { /> ) : activeTab === 'remediation' ? ( + ) : activeTab === 'activity' ? ( + ) : ( )} @@ -2545,6 +2546,135 @@ function ActivityRow({ item }: { item: ActivityItem }) { ); } +// Source filter chips for the host Activity tab. Audit is omitted: audit +// events carry no host_id, so a host-scoped audit filter is empty by design +// (host-relevant audit by resource is a Phase 2b follow-up). The unified +// feed otherwise covers monitoring, compliance, intelligence, and alerts. +const HOST_ACTIVITY_SOURCES: { id: ActivitySource | ''; label: string }[] = [ + { id: '', label: 'All' }, + { id: 'monitoring', label: 'Monitoring' }, + { id: 'transaction', label: 'Compliance' }, + { id: 'intelligence', label: 'Intelligence' }, + { id: 'alert', label: 'Alert' }, +]; + +// HostActivityTab is the full host-scoped activity feed (the "View all" +// target from the Recent activity card). It pages the unified +// /api/v1/activity?host_id=X endpoint (cursor pagination) and reuses +// ActivityRow + the shared eventDisplay helpers. Spec frontend-host-detail. +function HostActivityTab({ hostId }: { hostId: string }) { + const [source, setSource] = useState(''); + const q = useInfiniteQuery({ + queryKey: ['host', hostId, 'activity', source], + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => { + const { data, error, response } = await api.GET('/api/v1/activity', { + params: { + query: { + host_id: hostId, + limit: 50, + ...(source ? { source } : {}), + ...(pageParam ? { cursor: pageParam } : {}), + }, + }, + }); + if (error || !response.ok) { + throw new Error(apiErrorMessage(error, `Failed to load activity (${response.status})`)); + } + return data as unknown as { items: ActivityItem[]; next_cursor?: string | null }; + }, + getNextPageParam: (last) => last.next_cursor ?? undefined, + enabled: !!hostId, + }); + + const items = q.data?.pages.flatMap((p) => p.items ?? []) ?? []; + + return ( + +
+ {HOST_ACTIVITY_SOURCES.map((opt) => { + const active = source === opt.id; + return ( + + ); + })} +
+ + {q.isLoading ? ( +
Loading…
+ ) : q.isError ? ( +
+ {apiErrorMessage(q.error, 'Failed to load activity')}{' '} + +
+ ) : items.length === 0 ? ( + + ) : ( + <> +
    + {items.map((it) => ( + + ))} +
+ {q.hasNextPage ? ( + + ) : null} + + )} +
+ ); +} + // activitySeverityColors maps the closed severity enum onto the // existing OW color tokens. function activitySeverityColors(s: ActivitySeverity): { fg: string; dot: string } { diff --git a/frontend/tests/pages/host-detail-compliance-tab.test.tsx b/frontend/tests/pages/host-detail-compliance-tab.test.tsx index adb38d85..8def2862 100644 --- a/frontend/tests/pages/host-detail-compliance-tab.test.tsx +++ b/frontend/tests/pages/host-detail-compliance-tab.test.tsx @@ -156,8 +156,10 @@ describe('frontend-host-compliance-tab — structural', () => { expect(PAGE_SRC).toContain('"); + // remediation + activity joined overview + compliance as live (non-stub) tabs. + expect(PAGE_SRC).toContain( + "Exclude", + ); }); // @ac AC-02 diff --git a/frontend/tests/pages/host-detail-shell.test.ts b/frontend/tests/pages/host-detail-shell.test.ts index d2578872..6090701b 100644 --- a/frontend/tests/pages/host-detail-shell.test.ts +++ b/frontend/tests/pages/host-detail-shell.test.ts @@ -481,4 +481,25 @@ describe('frontend-host-detail v1.6.0 — per-host credential management + recon // Mutating controls gated on credential:write. expect(HOST_CRED_SRC).toContain("hasPermission('credential:write')"); }); + + // @ac AC-43 + test('frontend-host-detail/AC-43 — Activity tab is live (HostActivityTab), not a stub', () => { + // Activity is removed from the deferred-stub registry and routed to the + // host-scoped feed component. + expect(PAGE_SRC).toContain('function HostActivityTab'); + expect(PAGE_SRC).toMatch(/activeTab === 'activity' \? \(\s*/); + // Paged host-scoped feed via useInfiniteQuery with cursor pagination. + expect(PAGE_SRC).toContain('useInfiniteQuery'); + expect(PAGE_SRC).toMatch(/getNextPageParam:\s*\(last\)\s*=>\s*last\.next_cursor/); + expect(PAGE_SRC).toMatch(/host_id:\s*hostId/); + // Source filter chips + Load more + reuse of ActivityRow. + expect(PAGE_SRC).toContain('HOST_ACTIVITY_SOURCES'); + expect(PAGE_SRC).toMatch(/hasNextPage/); + expect(PAGE_SRC).toMatch(/Load more/); + // The Audit log tab remains a stub. + expect(PAGE_SRC).toMatch(/audit_log:\s*\n?\s*'Audit query API/); + }); }); diff --git a/specs/frontend/host-detail.spec.yaml b/specs/frontend/host-detail.spec.yaml index 8a65f999..5454b65c 100644 --- a/specs/frontend/host-detail.spec.yaml +++ b/specs/frontend/host-detail.spec.yaml @@ -1,7 +1,7 @@ spec: id: frontend-host-detail title: Host Detail page layout, behavior, and empty-state contracts - version: "1.6.0" + version: "1.7.0" status: approved tier: 2 @@ -70,7 +70,7 @@ spec: type: technical enforcement: error - id: C-04 - description: 'Tabs row MUST render all 10 prototype tabs in order: Overview, Compliance, Packages, Services, Users, Network, Audit log, Activity, Remediation, Terminal. Tabs whose backend subsystem is still deferred render an empty-state stub that NAMES the subsystem. v1.3.0: Compliance is live (frontend-host-compliance-tab) and the TAB_BACKEND_SUBSYSTEM stub registry no longer carries a compliance entry; Packages / Services / Users / Network are live per frontend-host-detail-inventory-tabs' + description: 'Tabs row MUST render all 10 prototype tabs in order: Overview, Compliance, Packages, Services, Users, Network, Audit log, Activity, Remediation, Terminal. Tabs whose backend subsystem is still deferred render an empty-state stub that NAMES the subsystem. v1.3.0: Compliance is live (frontend-host-compliance-tab) and the TAB_BACKEND_SUBSYSTEM stub registry no longer carries a compliance entry; Packages / Services / Users / Network are live per frontend-host-detail-inventory-tabs. v1.7.0: Activity is live (mounts HostActivityTab, the host-scoped /api/v1/activity feed) and is likewise removed from the TAB_BACKEND_SUBSYSTEM stub registry. The Audit log tab remains a stub (host-scoped audit by resource is a follow-up)' type: technical enforcement: error - id: C-05 @@ -254,3 +254,7 @@ spec: description: 'v1.6.0 - the Connectivity card "Edit credentials" button and the EditHostModal "Manage SSH credential" link both open HostCredentialModal for the host. HostCredentialModal resolves the source and renders the four tier transitions (clone default, set different host credential, edit override via PATCH, revert override via DELETE), each invalidating ["host-credential-resolve", hostId]; mutating controls are gated on credential:write. Source-inspection of HostDetailPage.tsx, EditHostModal.tsx, and HostCredentialModal.tsx.' priority: high references_constraints: [C-11, C-12] + - id: AC-43 + description: 'v1.7.0 source-inspection - the Activity tab mounts HostActivityTab (not TabStub): it is removed from the TAB_BACKEND_SUBSYSTEM stub registry, and the tab render switch routes activeTab === "activity" to . HostActivityTab pages GET /api/v1/activity?host_id=X via useInfiniteQuery (cursor pagination, getNextPageParam from next_cursor), renders rows with the shared ActivityRow, exposes source filter chips (All / Monitoring / Compliance / Intelligence / Alert) that drive the source query param, shows a Load more control gated on hasNextPage, and renders loading / error+Retry / "No activity yet" empty states. The Audit log tab still renders TabStub.' + priority: high + references_constraints: [C-04]