Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 133 additions & 3 deletions frontend/src/pages/HostDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<TabId, 'overview' | 'compliance' | 'remediation'>,
Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity'>,
string
> = {
packages: 'Server Intelligence collection — installed-package inventory deferred (BACKLOG).',
Expand All @@ -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.',
};

Expand Down Expand Up @@ -487,6 +486,8 @@ export function HostDetailPage() {
/>
) : activeTab === 'remediation' ? (
<RemediationTab hostId={detailQuery.data.host.id} />
) : activeTab === 'activity' ? (
<HostActivityTab hostId={detailQuery.data.host.id} />
) : (
<TabStub tab={activeTab} subsystem={TAB_BACKEND_SUBSYSTEM[activeTab]} />
)}
Expand Down Expand Up @@ -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<ActivitySource | ''>('');
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 (
<Card title="Activity">
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
{HOST_ACTIVITY_SOURCES.map((opt) => {
const active = source === opt.id;
return (
<button
key={opt.label}
type="button"
role="radio"
aria-checked={active}
onClick={() => setSource(opt.id)}
style={{
height: 26,
padding: '0 10px',
border: `1px solid ${active ? 'var(--ow-info)' : 'var(--ow-line)'}`,
background: active
? 'color-mix(in oklab, var(--ow-info) 18%, transparent)'
: 'transparent',
color: active ? 'var(--ow-fg-0)' : 'var(--ow-fg-2)',
fontFamily: 'inherit',
fontSize: 12,
borderRadius: 6,
cursor: 'pointer',
}}
>
{opt.label}
</button>
);
})}
</div>

{q.isLoading ? (
<div style={{ color: 'var(--ow-fg-2)', fontSize: 12 }}>Loading…</div>
) : q.isError ? (
<div
style={{
color: 'var(--ow-crit)',
fontSize: 12,
display: 'flex',
gap: 8,
alignItems: 'center',
}}
>
{apiErrorMessage(q.error, 'Failed to load activity')}{' '}
<button type="button" onClick={() => q.refetch()} style={smallTextBtn}>
<RefreshCw size={11} /> Retry
</button>
</div>
) : items.length === 0 ? (
<EmptyState
primary="No activity yet"
secondary="Sourced from the unified activity feed (band transitions, scan changes, intelligence diffs, alerts). The list will populate as the host accrues events."
/>
) : (
<>
<ol
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{items.map((it) => (
<ActivityRow key={`${it.source}-${it.id}`} item={it} />
))}
</ol>
{q.hasNextPage ? (
<button
type="button"
onClick={() => q.fetchNextPage()}
disabled={q.isFetchingNextPage}
style={{ ...smallTextBtn, marginTop: 12 }}
>
{q.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
) : null}
</>
)}
</Card>
);
}

// activitySeverityColors maps the closed severity enum onto the
// existing OW color tokens.
function activitySeverityColors(s: ActivitySeverity): { fg: string; dot: string } {
Expand Down
6 changes: 4 additions & 2 deletions frontend/tests/pages/host-detail-compliance-tab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,10 @@ describe('frontend-host-compliance-tab — structural', () => {
expect(PAGE_SRC).toContain('<ComplianceTab');
// The stub registry no longer carries a compliance entry.
expect(PAGE_SRC).not.toMatch(/^\s*compliance:\s*'/m);
// remediation joined overview + compliance as a live (non-stub) tab.
expect(PAGE_SRC).toContain("Exclude<TabId, 'overview' | 'compliance' | 'remediation'>");
// remediation + activity joined overview + compliance as live (non-stub) tabs.
expect(PAGE_SRC).toContain(
"Exclude<TabId, 'overview' | 'compliance' | 'remediation' | 'activity'>",
);
});

// @ac AC-02
Expand Down
21 changes: 21 additions & 0 deletions frontend/tests/pages/host-detail-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*<HostActivityTab/);
// The stub registry no longer carries an `activity` entry.
expect(PAGE_SRC).not.toMatch(/activity:\s*'Unified Activity feed/);
expect(PAGE_SRC).toMatch(/Exclude<TabId, 'overview' \| 'compliance' \| 'remediation' \| 'activity'>/);
// 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/);
});
});
8 changes: 6 additions & 2 deletions specs/frontend/host-detail.spec.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>. 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]
Loading