Skip to content

Commit fa2ac15

Browse files
feat(host-detail): live Activity tab (host-scoped feed) (Phase 2a) (#618)
frontend-host-detail v1.7.0 (AC-43). Phase 2a of the activity readability initiative (docs/engineering/activity_readability_plan.md). The host-detail Activity tab was a deferred stub, so the Recent-activity card's 'View all' link dead-ended. It now mounts HostActivityTab — the host-scoped unified feed at GET /api/v1/activity?host_id=X with cursor pagination (useInfiniteQuery + Load more), source filter chips (All/Monitoring/Compliance/Intelligence/Alert), and the shared ActivityRow + eventDisplay rendering. Activity joins Compliance/Remediation as a live tab (removed from the TAB_BACKEND_SUBSYSTEM stub registry), mirroring the exact precedent set when Compliance went live. The Audit log tab stays a stub for now: audit events carry no host_id, so a host-scoped audit view needs backend resource=host filtering (Phase 2b), which is also where the keep-vs-drop decision for that tab is made. Verified live: the owas-tst01 Activity tab renders the paginated feed with working filter chips. Full frontend suite (320) + specter (111) green.
1 parent 022de24 commit fa2ac15

4 files changed

Lines changed: 164 additions & 7 deletions

File tree

frontend/src/pages/HostDetailPage.tsx

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
1+
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
22
import { useParams, useSearch, useNavigate, Link } from '@tanstack/react-router';
33
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
44
import {
@@ -182,7 +182,7 @@ const TAB_ORDER: { id: TabId; label: string; icon: LucideIcon }[] = [
182182
// Backend subsystem that populates each tab when it lands. Surfaces
183183
// inside the per-tab empty state so operators know what's deferred.
184184
const TAB_BACKEND_SUBSYSTEM: Record<
185-
Exclude<TabId, 'overview' | 'compliance' | 'remediation'>,
185+
Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity'>,
186186
string
187187
> = {
188188
packages: 'Server Intelligence collection — installed-package inventory deferred (BACKLOG).',
@@ -191,7 +191,6 @@ const TAB_BACKEND_SUBSYSTEM: Record<
191191
network: 'Server Intelligence collection — interfaces and firewall rules deferred (BACKLOG).',
192192
audit_log:
193193
'Audit query API — host-scoped audit feed deferred to the unified /activity page (BACKLOG).',
194-
activity: 'Unified Activity feed — combined transactions + audits + alerts deferred (BACKLOG).',
195194
terminal: 'Web terminal — SSH-in-browser deferred; use a host-side SSH client in the meantime.',
196195
};
197196

@@ -487,6 +486,8 @@ export function HostDetailPage() {
487486
/>
488487
) : activeTab === 'remediation' ? (
489488
<RemediationTab hostId={detailQuery.data.host.id} />
489+
) : activeTab === 'activity' ? (
490+
<HostActivityTab hostId={detailQuery.data.host.id} />
490491
) : (
491492
<TabStub tab={activeTab} subsystem={TAB_BACKEND_SUBSYSTEM[activeTab]} />
492493
)}
@@ -2545,6 +2546,135 @@ function ActivityRow({ item }: { item: ActivityItem }) {
25452546
);
25462547
}
25472548

2549+
// Source filter chips for the host Activity tab. Audit is omitted: audit
2550+
// events carry no host_id, so a host-scoped audit filter is empty by design
2551+
// (host-relevant audit by resource is a Phase 2b follow-up). The unified
2552+
// feed otherwise covers monitoring, compliance, intelligence, and alerts.
2553+
const HOST_ACTIVITY_SOURCES: { id: ActivitySource | ''; label: string }[] = [
2554+
{ id: '', label: 'All' },
2555+
{ id: 'monitoring', label: 'Monitoring' },
2556+
{ id: 'transaction', label: 'Compliance' },
2557+
{ id: 'intelligence', label: 'Intelligence' },
2558+
{ id: 'alert', label: 'Alert' },
2559+
];
2560+
2561+
// HostActivityTab is the full host-scoped activity feed (the "View all"
2562+
// target from the Recent activity card). It pages the unified
2563+
// /api/v1/activity?host_id=X endpoint (cursor pagination) and reuses
2564+
// ActivityRow + the shared eventDisplay helpers. Spec frontend-host-detail.
2565+
function HostActivityTab({ hostId }: { hostId: string }) {
2566+
const [source, setSource] = useState<ActivitySource | ''>('');
2567+
const q = useInfiniteQuery({
2568+
queryKey: ['host', hostId, 'activity', source],
2569+
initialPageParam: undefined as string | undefined,
2570+
queryFn: async ({ pageParam }) => {
2571+
const { data, error, response } = await api.GET('/api/v1/activity', {
2572+
params: {
2573+
query: {
2574+
host_id: hostId,
2575+
limit: 50,
2576+
...(source ? { source } : {}),
2577+
...(pageParam ? { cursor: pageParam } : {}),
2578+
},
2579+
},
2580+
});
2581+
if (error || !response.ok) {
2582+
throw new Error(apiErrorMessage(error, `Failed to load activity (${response.status})`));
2583+
}
2584+
return data as unknown as { items: ActivityItem[]; next_cursor?: string | null };
2585+
},
2586+
getNextPageParam: (last) => last.next_cursor ?? undefined,
2587+
enabled: !!hostId,
2588+
});
2589+
2590+
const items = q.data?.pages.flatMap((p) => p.items ?? []) ?? [];
2591+
2592+
return (
2593+
<Card title="Activity">
2594+
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
2595+
{HOST_ACTIVITY_SOURCES.map((opt) => {
2596+
const active = source === opt.id;
2597+
return (
2598+
<button
2599+
key={opt.label}
2600+
type="button"
2601+
role="radio"
2602+
aria-checked={active}
2603+
onClick={() => setSource(opt.id)}
2604+
style={{
2605+
height: 26,
2606+
padding: '0 10px',
2607+
border: `1px solid ${active ? 'var(--ow-info)' : 'var(--ow-line)'}`,
2608+
background: active
2609+
? 'color-mix(in oklab, var(--ow-info) 18%, transparent)'
2610+
: 'transparent',
2611+
color: active ? 'var(--ow-fg-0)' : 'var(--ow-fg-2)',
2612+
fontFamily: 'inherit',
2613+
fontSize: 12,
2614+
borderRadius: 6,
2615+
cursor: 'pointer',
2616+
}}
2617+
>
2618+
{opt.label}
2619+
</button>
2620+
);
2621+
})}
2622+
</div>
2623+
2624+
{q.isLoading ? (
2625+
<div style={{ color: 'var(--ow-fg-2)', fontSize: 12 }}>Loading…</div>
2626+
) : q.isError ? (
2627+
<div
2628+
style={{
2629+
color: 'var(--ow-crit)',
2630+
fontSize: 12,
2631+
display: 'flex',
2632+
gap: 8,
2633+
alignItems: 'center',
2634+
}}
2635+
>
2636+
{apiErrorMessage(q.error, 'Failed to load activity')}{' '}
2637+
<button type="button" onClick={() => q.refetch()} style={smallTextBtn}>
2638+
<RefreshCw size={11} /> Retry
2639+
</button>
2640+
</div>
2641+
) : items.length === 0 ? (
2642+
<EmptyState
2643+
primary="No activity yet"
2644+
secondary="Sourced from the unified activity feed (band transitions, scan changes, intelligence diffs, alerts). The list will populate as the host accrues events."
2645+
/>
2646+
) : (
2647+
<>
2648+
<ol
2649+
style={{
2650+
listStyle: 'none',
2651+
padding: 0,
2652+
margin: 0,
2653+
display: 'flex',
2654+
flexDirection: 'column',
2655+
gap: 8,
2656+
}}
2657+
>
2658+
{items.map((it) => (
2659+
<ActivityRow key={`${it.source}-${it.id}`} item={it} />
2660+
))}
2661+
</ol>
2662+
{q.hasNextPage ? (
2663+
<button
2664+
type="button"
2665+
onClick={() => q.fetchNextPage()}
2666+
disabled={q.isFetchingNextPage}
2667+
style={{ ...smallTextBtn, marginTop: 12 }}
2668+
>
2669+
{q.isFetchingNextPage ? 'Loading…' : 'Load more'}
2670+
</button>
2671+
) : null}
2672+
</>
2673+
)}
2674+
</Card>
2675+
);
2676+
}
2677+
25482678
// activitySeverityColors maps the closed severity enum onto the
25492679
// existing OW color tokens.
25502680
function activitySeverityColors(s: ActivitySeverity): { fg: string; dot: string } {

frontend/tests/pages/host-detail-compliance-tab.test.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,10 @@ describe('frontend-host-compliance-tab — structural', () => {
156156
expect(PAGE_SRC).toContain('<ComplianceTab');
157157
// The stub registry no longer carries a compliance entry.
158158
expect(PAGE_SRC).not.toMatch(/^\s*compliance:\s*'/m);
159-
// remediation joined overview + compliance as a live (non-stub) tab.
160-
expect(PAGE_SRC).toContain("Exclude<TabId, 'overview' | 'compliance' | 'remediation'>");
159+
// remediation + activity joined overview + compliance as live (non-stub) tabs.
160+
expect(PAGE_SRC).toContain(
161+
"Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity'>",
162+
);
161163
});
162164

163165
// @ac AC-02

frontend/tests/pages/host-detail-shell.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,4 +481,25 @@ describe('frontend-host-detail v1.6.0 — per-host credential management + recon
481481
// Mutating controls gated on credential:write.
482482
expect(HOST_CRED_SRC).toContain("hasPermission('credential:write')");
483483
});
484+
485+
// @ac AC-43
486+
test('frontend-host-detail/AC-43 — Activity tab is live (HostActivityTab), not a stub', () => {
487+
// Activity is removed from the deferred-stub registry and routed to the
488+
// host-scoped feed component.
489+
expect(PAGE_SRC).toContain('function HostActivityTab');
490+
expect(PAGE_SRC).toMatch(/activeTab === 'activity' \? \(\s*<HostActivityTab/);
491+
// The stub registry no longer carries an `activity` entry.
492+
expect(PAGE_SRC).not.toMatch(/activity:\s*'Unified Activity feed/);
493+
expect(PAGE_SRC).toMatch(/Exclude<TabId, 'overview' \| 'compliance' \| 'remediation' \| 'activity'>/);
494+
// Paged host-scoped feed via useInfiniteQuery with cursor pagination.
495+
expect(PAGE_SRC).toContain('useInfiniteQuery');
496+
expect(PAGE_SRC).toMatch(/getNextPageParam:\s*\(last\)\s*=>\s*last\.next_cursor/);
497+
expect(PAGE_SRC).toMatch(/host_id:\s*hostId/);
498+
// Source filter chips + Load more + reuse of ActivityRow.
499+
expect(PAGE_SRC).toContain('HOST_ACTIVITY_SOURCES');
500+
expect(PAGE_SRC).toMatch(/hasNextPage/);
501+
expect(PAGE_SRC).toMatch(/Load more/);
502+
// The Audit log tab remains a stub.
503+
expect(PAGE_SRC).toMatch(/audit_log:\s*\n?\s*'Audit query API/);
504+
});
484505
});

specs/frontend/host-detail.spec.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
spec:
22
id: frontend-host-detail
33
title: Host Detail page layout, behavior, and empty-state contracts
4-
version: "1.6.0"
4+
version: "1.7.0"
55
status: approved
66
tier: 2
77

@@ -70,7 +70,7 @@ spec:
7070
type: technical
7171
enforcement: error
7272
- id: C-04
73-
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'
73+
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)'
7474
type: technical
7575
enforcement: error
7676
- id: C-05
@@ -254,3 +254,7 @@ spec:
254254
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.'
255255
priority: high
256256
references_constraints: [C-11, C-12]
257+
- id: AC-43
258+
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>. 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.'
259+
priority: high
260+
references_constraints: [C-04]

0 commit comments

Comments
 (0)