From dac207edd27245353f5ada2f98e3d039e0319b15 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 03:58:34 +0000 Subject: [PATCH 1/2] Milestone 18e: Metrics + History + Exports + POAM tabs Co-Authored-By: Bryce Anglin --- e2e/tests/web.spec.ts | 77 ++++ web/src/app/pages/collections/detail.tsx | 34 +- web/src/app/pages/collections/exports-tab.tsx | 255 +++++++++++ web/src/app/pages/collections/history-tab.tsx | 398 +++++++++++++++++ web/src/app/pages/collections/metrics-tab.tsx | 301 +++++++++++++ web/src/app/pages/collections/poam-tab.tsx | 248 +++++++++++ web/src/lib/api/hooks.ts | 119 ++++++ web/src/lib/api/index.ts | 400 ++++++++++++++++++ 8 files changed, 1831 insertions(+), 1 deletion(-) create mode 100644 web/src/app/pages/collections/exports-tab.tsx create mode 100644 web/src/app/pages/collections/history-tab.tsx create mode 100644 web/src/app/pages/collections/metrics-tab.tsx create mode 100644 web/src/app/pages/collections/poam-tab.tsx diff --git a/e2e/tests/web.spec.ts b/e2e/tests/web.spec.ts index 4569b3d..862b026 100644 --- a/e2e/tests/web.spec.ts +++ b/e2e/tests/web.spec.ts @@ -131,6 +131,83 @@ test.describe('Web SPA', () => { ).toBeVisible({ timeout: 10_000 }) }) + test('metrics tab shows KPI grid and empty state tables (M18e)', async ({ + page, + }) => { + await page.goto(`${urls.web}/collections`) + const collectionName = `e2e-metrics-${Date.now()}` + await page.getByTestId('new-collection-button').click() + const cdialog = page.getByTestId('new-collection-dialog') + await cdialog.getByTestId('new-collection-name-input').fill(collectionName) + await cdialog.getByTestId('new-collection-submit').click() + await expect(page).toHaveURL(/\/collections\/\d+$/, { timeout: 10_000 }) + + await page.getByTestId('collection-tab-metrics').click() + await expect(page.getByTestId('collection-metrics-tab')).toBeVisible() + + // KPI grid is rendered (even though counts are zero for a fresh collection). + await expect(page.getByTestId('metrics-kpi-grid')).toBeVisible({ + timeout: 10_000, + }) + await expect(page.getByTestId('metrics-kpi-assets')).toContainText('0') + // Per-asset and per-stig tables show empty state. + await expect(page.getByTestId('metrics-by-asset-empty')).toBeVisible() + await expect(page.getByTestId('metrics-by-stig-empty')).toBeVisible() + }) + + test('history tab shows stats and empty entries state (M18e)', async ({ + page, + }) => { + await page.goto(`${urls.web}/collections`) + const collectionName = `e2e-history-${Date.now()}` + await page.getByTestId('new-collection-button').click() + const cdialog = page.getByTestId('new-collection-dialog') + await cdialog.getByTestId('new-collection-name-input').fill(collectionName) + await cdialog.getByTestId('new-collection-submit').click() + await expect(page).toHaveURL(/\/collections\/\d+$/, { timeout: 10_000 }) + + await page.getByTestId('collection-tab-history').click() + await expect(page.getByTestId('collection-history-tab')).toBeVisible() + + // Stats panel is visible. + await expect(page.getByTestId('history-stats')).toBeVisible({ + timeout: 10_000, + }) + await expect(page.getByTestId('history-total-entries')).toContainText('0') + // No history entries yet → empty state. + await expect(page.getByTestId('history-empty')).toBeVisible() + }) + + test('exports tab renders the download form (M18e)', async ({ page }) => { + await page.goto(`${urls.web}/collections`) + const collectionName = `e2e-exports-${Date.now()}` + await page.getByTestId('new-collection-button').click() + const cdialog = page.getByTestId('new-collection-dialog') + await cdialog.getByTestId('new-collection-name-input').fill(collectionName) + await cdialog.getByTestId('new-collection-submit').click() + await expect(page).toHaveURL(/\/collections\/\d+$/, { timeout: 10_000 }) + + await page.getByTestId('collection-tab-exports').click() + await expect(page.getByTestId('collection-exports-tab')).toBeVisible() + await expect(page.getByTestId('export-format-toggle')).toBeVisible() + await expect(page.getByTestId('export-download-button')).toBeVisible() + }) + + test('POAM tab renders the download form (M18e)', async ({ page }) => { + await page.goto(`${urls.web}/collections`) + const collectionName = `e2e-poam-${Date.now()}` + await page.getByTestId('new-collection-button').click() + const cdialog = page.getByTestId('new-collection-dialog') + await cdialog.getByTestId('new-collection-name-input').fill(collectionName) + await cdialog.getByTestId('new-collection-submit').click() + await expect(page).toHaveURL(/\/collections\/\d+$/, { timeout: 10_000 }) + + await page.getByTestId('collection-tab-poam').click() + await expect(page.getByTestId('collection-poam-tab')).toBeVisible() + await expect(page.getByTestId('poam-aggregator')).toBeVisible() + await expect(page.getByTestId('poam-download-button')).toBeVisible() + }) + test('dry-run batch review surfaces will-insert counts (M18d)', async ({ page, }) => { diff --git a/web/src/app/pages/collections/detail.tsx b/web/src/app/pages/collections/detail.tsx index 02b0606..e1dd129 100644 --- a/web/src/app/pages/collections/detail.tsx +++ b/web/src/app/pages/collections/detail.tsx @@ -8,6 +8,10 @@ import { ArrowLeft, Loader2 } from 'lucide-react' import { Link, useParams } from 'react-router-dom' import { AssetsTab } from '../assets/assets-tab' +import { ExportsTab } from './exports-tab' +import { HistoryTab } from './history-tab' +import { MetricsTab } from './metrics-tab' +import { PoamTab } from './poam-tab' import { ReviewsTab } from './reviews-tab' import { Card, @@ -187,12 +191,40 @@ export function CollectionDetailPage() { )} + {visibleTabs.some((t) => t.value === 'metrics') && ( + + + + )} + + {visibleTabs.some((t) => t.value === 'history') && ( + + + + )} + + {visibleTabs.some((t) => t.value === 'exports') && ( + + + + )} + + {visibleTabs.some((t) => t.value === 'poam') && ( + + + + )} + {visibleTabs .filter( (t) => t.value !== 'overview' && t.value !== 'assets' && - t.value !== 'reviews', + t.value !== 'reviews' && + t.value !== 'metrics' && + t.value !== 'history' && + t.value !== 'exports' && + t.value !== 'poam', ) .map((tab) => ( diff --git a/web/src/app/pages/collections/exports-tab.tsx b/web/src/app/pages/collections/exports-tab.tsx new file mode 100644 index 0000000..d5b4def --- /dev/null +++ b/web/src/app/pages/collections/exports-tab.tsx @@ -0,0 +1,255 @@ +// Exports tab inside the Collection detail page. Wraps the three +// `/collections/{cid}/archive/{ckl|cklb|xccdf}` endpoints in one +// form. The user picks an asset subset and a format; the API streams +// a ZIP that we trigger as a browser download. The default selection +// is "all assets in the Collection"; omitting per-asset `stigs` +// requests the default revisions of every benchmark mapped to the +// asset (and visible to the caller). + +import { Download, Loader2 } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Label } from '@/components/ui/label' +import { useAssets } from '@/lib/api/hooks' +import { + downloadCklArchive, + downloadCklbArchive, + downloadXccdfArchive, + type AssetStigSelection, + type CklMode, +} from '@/lib/api' + +interface ExportsTabProps { + collectionId: string +} + +type Format = 'ckl' | 'cklb' | 'xccdf' + +export function ExportsTab({ collectionId }: ExportsTabProps) { + const assets = useAssets({ collectionId }) + + const [selected, setSelected] = React.useState>(new Set()) + const [format, setFormat] = React.useState('ckl') + const [mode, setMode] = React.useState('mono') + const [pending, setPending] = React.useState(false) + const [error, setError] = React.useState(null) + const [lastDownload, setLastDownload] = React.useState(null) + + function toggleAll(checked: boolean) { + if (!checked) { + setSelected(new Set()) + return + } + setSelected(new Set((assets.data ?? []).map((a) => a.assetId))) + } + + function toggleOne(assetId: string, checked: boolean) { + setSelected((prev) => { + const next = new Set(prev) + if (checked) next.add(assetId) + else next.delete(assetId) + return next + }) + } + + async function onDownload() { + setError(null) + setLastDownload(null) + const list = assets.data ?? [] + const effective = selected.size === 0 ? list.map((a) => a.assetId) : Array.from(selected) + if (effective.length === 0) { + setError('No assets in this Collection to export.') + return + } + const selections: AssetStigSelection[] = effective.map((assetId) => ({ + assetId, + })) + setPending(true) + try { + if (format === 'ckl') { + await downloadCklArchive(collectionId, selections, mode) + } else if (format === 'cklb') { + await downloadCklbArchive(collectionId, selections, mode) + } else { + await downloadXccdfArchive(collectionId, selections) + } + setLastDownload( + `Downloaded ${format.toUpperCase()} archive for ${effective.length} asset${effective.length === 1 ? '' : 's'}.`, + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Download failed.') + } finally { + setPending(false) + } + } + + const allChecked = + selected.size > 0 && selected.size === (assets.data ?? []).length + + return ( +
+ + + Checklist exports +

+ Generates a ZIP containing one file per (asset, STIG) by default. + If no assets are selected, every asset in the Collection is + included. Switch to mono{' '} + mode for a single combined file per asset (CKL / CKLB only). +

+
+ +
+
+ +
+ {(['ckl', 'cklb', 'xccdf'] as Format[]).map((f) => ( + + ))} +
+
+ + {format !== 'xccdf' && ( +
+ +
+ {(['mono', 'multi'] as CklMode[]).map((m) => ( + + ))} +
+
+ )} +
+ +
+
+ + +
+
+ {assets.isLoading ? ( + + ) : (assets.data ?? []).length === 0 ? ( +

+ No assets in this Collection yet. +

+ ) : ( +
    + {(assets.data ?? []).map((a) => ( +
  • + +
  • + ))} +
+ )} +
+

+ Leave empty to export every asset. +

+
+ + {error && ( +

+ {error} +

+ )} + {lastDownload && ( +

+ {lastDownload} +

+ )} + + +
+
+
+ ) +} + +function Spinner({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} diff --git a/web/src/app/pages/collections/history-tab.tsx b/web/src/app/pages/collections/history-tab.tsx new file mode 100644 index 0000000..a6d3f88 --- /dev/null +++ b/web/src/app/pages/collections/history-tab.tsx @@ -0,0 +1,398 @@ +// Review-history tab inside the Collection detail page. Wraps the +// `/collections/{cid}/review-history` family of endpoints — list, +// stats (with optional per-asset projection), and the Manage-gated +// retention delete. + +import { Loader2, Trash2 } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + useAssets, + useDeleteReviewHistory, + useReviewHistory, + useReviewHistoryStats, + type ReviewHistoryAsset, + type ReviewHistoryFilters, +} from '@/lib/api/hooks' +import type { ReviewStatusLabel } from '@/lib/api' + +interface HistoryTabProps { + collectionId: string + role: number | null +} + +const STATUS_OPTIONS: { value: ReviewStatusLabel | ''; label: string }[] = [ + { value: '', label: 'Any status' }, + { value: 'saved', label: 'Saved' }, + { value: 'submitted', label: 'Submitted' }, + { value: 'accepted', label: 'Accepted' }, + { value: 'rejected', label: 'Rejected' }, +] + +export function HistoryTab({ collectionId, role }: HistoryTabProps) { + const canManage = role !== null && role >= 3 + const assets = useAssets({ collectionId }) + + const [assetId, setAssetId] = React.useState('') + const [ruleId, setRuleId] = React.useState('') + const [status, setStatus] = React.useState('') + const [startDate, setStartDate] = React.useState('') + const [endDate, setEndDate] = React.useState('') + + const filters: ReviewHistoryFilters = React.useMemo(() => { + const f: ReviewHistoryFilters = {} + if (assetId) f.assetId = assetId + if (ruleId) f.ruleId = ruleId.trim() + if (status) f.status = status + if (startDate) f.startDate = new Date(startDate).toISOString() + if (endDate) f.endDate = new Date(endDate).toISOString() + return f + }, [assetId, ruleId, status, startDate, endDate]) + + const stats = useReviewHistoryStats(collectionId, { + projection: 'asset', + ...filters, + }) + const list = useReviewHistory(collectionId, filters) + const remove = useDeleteReviewHistory() + + const [retention, setRetention] = React.useState('') + const [deleteMessage, setDeleteMessage] = React.useState(null) + + async function onDelete() { + setDeleteMessage(null) + if (!retention) { + setDeleteMessage( + 'Retention date is required; otherwise the entire history would be erased.', + ) + return + } + if ( + !confirm( + `Delete history entries older than ${retention}${assetId ? ` for the selected asset` : ''}? This cannot be undone.`, + ) + ) { + return + } + try { + const resp = await remove.mutateAsync({ + collectionId, + input: { + retentionDate: new Date(retention).toISOString(), + ...(assetId ? { assetId } : {}), + }, + }) + setDeleteMessage( + `Removed ${resp.HistoryEntriesDeleted} history entr${resp.HistoryEntriesDeleted === 1 ? 'y' : 'ies'}.`, + ) + } catch (err) { + setDeleteMessage( + err instanceof Error ? err.message : 'Delete failed.', + ) + } + } + + return ( +
+ + + Review history +

+ Audit trail of every review state-change. Filters apply to both + the list and the stats panel below. +

+
+ +
+
+ + +
+
+ + setRuleId(e.target.value)} + placeholder="SV-…" + data-testid="history-rule-input" + /> +
+
+ + +
+
+ + setStartDate(e.target.value)} + data-testid="history-start-input" + /> +
+
+ + setEndDate(e.target.value)} + data-testid="history-end-input" + /> +
+
+
+
+ + + + Stats + + + {stats.isLoading ? ( + + ) : stats.isError ? ( +

+ {(stats.error as Error).message} +

+ ) : stats.data ? ( +
+
+ + + +
+ {(stats.data.assetHistoryEntryCounts ?? []).length > 0 && ( +
+ + Per-asset breakdown + +
    + {(stats.data.assetHistoryEntryCounts ?? []).map((a) => ( +
  • + {a.assetId}: {a.historyEntryCount ?? 0} entr + {a.historyEntryCount === 1 ? 'y' : 'ies'} + {a.oldestHistoryEntry + ? ` (oldest ${formatTs(a.oldestHistoryEntry)})` + : ''} +
  • + ))} +
+
+ )} +
+ ) : null} +
+
+ + + + Entries + + + {list.isLoading ? ( + + ) : list.isError ? ( +

+ {(list.error as Error).message} +

+ ) : (list.data ?? []).length === 0 ? ( +

+ No history entries match the current filter. +

+ ) : ( + + )} +
+
+ + {canManage && ( + + + Prune old history +

+ Removes review-history rows strictly older than the retention + date. If an asset is selected above it scopes the delete to + that asset only. +

+
+ +
+
+ + setRetention(e.target.value)} + data-testid="history-retention-input" + /> +
+
+ +
+
+ {deleteMessage && ( +

+ {deleteMessage} +

+ )} +
+
+ )} +
+ ) +} + +function Spinner({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} + +function Kpi({ + label, + value, + testId, +}: { + label: string + value: string + testId?: string +}) { + return ( +
+
+ {label} +
+
{value}
+
+ ) +} + +function HistoryList({ rows }: { rows: ReviewHistoryAsset[] }) { + const flat = rows.flatMap((asset) => + asset.reviewHistories.flatMap((rule) => + rule.history.map((h) => ({ + assetId: asset.assetId, + ruleId: rule.ruleId, + ...h, + })), + ), + ) + const sorted = [...flat].sort((a, b) => (a.ts < b.ts ? 1 : -1)) + return ( +
+ + + + + + + + + + + + + {sorted.map((r, i) => ( + + + + + + + + + ))} + +
WhenAssetRuleResultStatusUser
+ {formatTs(r.ts)} + {r.assetId}{r.ruleId}{r.result}{r.status?.label ?? '\u2014'}{r.username ?? r.userId ?? '\u2014'}
+
+ ) +} + +function formatTs(ts: string | null | undefined): string { + if (!ts) return '\u2014' + try { + return new Date(ts).toLocaleString() + } catch { + return ts + } +} diff --git a/web/src/app/pages/collections/metrics-tab.tsx b/web/src/app/pages/collections/metrics-tab.tsx new file mode 100644 index 0000000..e80e38c --- /dev/null +++ b/web/src/app/pages/collections/metrics-tab.tsx @@ -0,0 +1,301 @@ +// Metrics tab inside the Collection detail page. Surfaces the +// `/collections/{cid}/metrics/summary*` endpoint family in three +// stacked panels: a collection-wide KPI strip, a per-asset table, and +// a per-STIG table. Everything is read-only, scoped to +// `stig-manager:collection:read`. Lower roles still see the metrics +// because the API allows it; the form controls don't exist here. + +import { Loader2 } from 'lucide-react' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + useMetricsByAsset, + useMetricsByStig, + useMetricsCollection, + type MetricsSummaryAggAsset, + type MetricsSummaryAggStig, +} from '@/lib/api/hooks' +import type { MetricsSummary } from '@/lib/api' + +interface MetricsTabProps { + collectionId: string +} + +export function MetricsTab({ collectionId }: MetricsTabProps) { + const total = useMetricsCollection(collectionId) + const byAsset = useMetricsByAsset(collectionId) + const byStig = useMetricsByStig(collectionId) + + return ( +
+ + + Collection metrics +

+ Aggregate counts across all assets and STIGs in this Collection. + Severity counts are independent of result counts — for example, an + unassessed High-severity rule contributes to both + {' '}assessmentsBySeverity.high{' '} + (denominator) and findings.high when failed. +

+
+ + {total.isLoading ? ( + + ) : total.isError ? ( +

+ {(total.error as Error).message} +

+ ) : total.data ? ( +
+ + + + + + + + + + +
+ ) : null} +
+
+ + + + By asset +

+ One row per asset. Findings shown as high / medium / low. +

+
+ + {byAsset.isLoading ? ( + + ) : byAsset.isError ? ( +

+ {(byAsset.error as Error).message} +

+ ) : (byAsset.data ?? []).length === 0 ? ( +

+ No assets mapped to STIGs yet. +

+ ) : ( + + )} +
+
+ + + + By STIG +

+ One row per benchmark mapped to at least one asset. +

+
+ + {byStig.isLoading ? ( + + ) : byStig.isError ? ( +

+ {(byStig.error as Error).message} +

+ ) : (byStig.data ?? []).length === 0 ? ( +

+ No STIGs mapped to assets in this Collection. +

+ ) : ( + + )} +
+
+
+ ) +} + +function Spinner({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} + +function Kpi({ label, value }: { label: string; value: number }) { + return ( +
+
+ {label} +
+
{value}
+
+ ) +} + +function ResultCounts({ metrics }: { metrics: MetricsSummary['metrics'] }) { + return ( +
+
+ Results +
+
+ + + + +
+
+ ) +} + +function StatusCounts({ metrics }: { metrics: MetricsSummary['metrics'] }) { + return ( +
+
+ Statuses +
+
+ + + + +
+
+ ) +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function AssetTable({ rows }: { rows: MetricsSummaryAggAsset[] }) { + return ( +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
AssetSTIGsAssessedFindings (H/M/L)PassFail
{r.name} + {r.benchmarkIds.length} + + {r.metrics.assessed} + + {r.metrics.findings.high}/{r.metrics.findings.medium}/ + {r.metrics.findings.low} + + {r.metrics.results.pass} + + {r.metrics.results.fail} +
+
+ ) +} + +function StigTable({ rows }: { rows: MetricsSummaryAggStig[] }) { + return ( +
+ + + + + + + + + + + {rows.map((r) => ( + + + + + + + ))} + +
BenchmarkAssetsRulesFindings (H/M/L)
+ {r.benchmarkId} + {r.title && ( + + {r.title} + + )} + {r.assets} + {r.ruleCount ?? '\u2014'} + + {r.metrics.findings.high}/{r.metrics.findings.medium}/ + {r.metrics.findings.low} +
+
+ ) +} + +function slug(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') +} diff --git a/web/src/app/pages/collections/poam-tab.tsx b/web/src/app/pages/collections/poam-tab.tsx new file mode 100644 index 0000000..5e06e9e --- /dev/null +++ b/web/src/app/pages/collections/poam-tab.tsx @@ -0,0 +1,248 @@ +// POAM tab inside the Collection detail page. Wraps the +// `GET /collections/{cid}/poam` endpoint which streams an xlsx +// spreadsheet for either the EMASS or MCCAST template. The form +// mirrors the spec's query parameters: aggregator, format, +// acceptedOnly, benchmarkId, assetId, date, office, status, and the +// mccast-specific mccastPackageId / mccastAuthName. + +import { Download, Loader2 } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useAssets, useCollectionStigs } from '@/lib/api/hooks' +import { + downloadPoam, + type PoamAggregator, + type PoamFormat, +} from '@/lib/api' + +interface PoamTabProps { + collectionId: string +} + +export function PoamTab({ collectionId }: PoamTabProps) { + const assets = useAssets({ collectionId }) + const stigs = useCollectionStigs(collectionId) + + const [aggregator, setAggregator] = React.useState('groupId') + const [format, setFormat] = React.useState('emass') + const [acceptedOnly, setAcceptedOnly] = React.useState(false) + const [benchmarkId, setBenchmarkId] = React.useState('') + const [assetId, setAssetId] = React.useState('') + const [date, setDate] = React.useState('') + const [office, setOffice] = React.useState('') + const [status, setStatus] = React.useState('') + const [mccastPackageId, setMccastPackageId] = React.useState('') + const [mccastAuthName, setMccastAuthName] = React.useState('') + + const [pending, setPending] = React.useState(false) + const [error, setError] = React.useState(null) + const [success, setSuccess] = React.useState(null) + + async function onDownload() { + setError(null) + setSuccess(null) + setPending(true) + try { + await downloadPoam(collectionId, { + aggregator, + format, + acceptedOnly: acceptedOnly || undefined, + benchmarkId: benchmarkId || undefined, + assetId: assetId || undefined, + date: date || undefined, + office: office || undefined, + status: status || undefined, + mccastPackageId: mccastPackageId || undefined, + mccastAuthName: mccastAuthName || undefined, + }) + setSuccess( + `Downloaded ${format.toUpperCase()} POAM (${aggregator} aggregation).`, + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'POAM download failed.') + } finally { + setPending(false) + } + } + + return ( +
+ + + POA&M +

+ Generates a Plan of Action & Milestones spreadsheet in xlsx + format. Findings are aggregated by either{' '} + groupId (V-…) or{' '} + ruleId (SV-…). The + downloaded file uses either the EMASS or MCCAST template. +

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + setDate(e.target.value)} + placeholder="01/15/2026" + data-testid="poam-date" + /> +
+
+ + setOffice(e.target.value)} + data-testid="poam-office" + /> +
+
+ + setStatus(e.target.value)} + placeholder="Ongoing" + data-testid="poam-status" + /> +
+
+ +
+
+ + {format === 'mccast' && ( +
+
+ + setMccastPackageId(e.target.value)} + data-testid="poam-mccast-package-id" + /> +
+
+ + setMccastAuthName(e.target.value)} + data-testid="poam-mccast-auth-name" + /> +
+
+ )} + + {error && ( +

+ {error} +

+ )} + {success && ( +

+ {success} +

+ )} + + +
+
+
+ ) +} diff --git a/web/src/lib/api/hooks.ts b/web/src/lib/api/hooks.ts index 9fc22b3..25454b7 100644 --- a/web/src/lib/api/hooks.ts +++ b/web/src/lib/api/hooks.ts @@ -15,6 +15,7 @@ import { createAsset, createCollection, deleteAsset, + deleteReviewHistory, fetchAppInfo, fetchAsset, fetchAssets, @@ -23,7 +24,12 @@ import { fetchCollections, fetchCollectionStigs, fetchCurrentUser, + fetchMetricsSummaryByAsset, + fetchMetricsSummaryByStig, + fetchMetricsSummaryCollection, fetchReviewByAssetRule, + fetchReviewHistory, + fetchReviewHistoryStats, fetchReviewsByAsset, fetchRulesByRevision, postReviewBatch, @@ -38,10 +44,17 @@ import { type CollectionSummary, type CreateCollectionInput, type CurrentUser, + type DeleteReviewHistoryInput, + type MetricsSummaryAggAsset, + type MetricsSummaryAggCollection, + type MetricsSummaryAggStig, type Review, type ReviewBatchInput, type ReviewBatchResponse, type ReviewBatchResponseDryRun, + type ReviewHistoryAsset, + type ReviewHistoryFilters, + type ReviewHistoryStats, type ReviewPutInput, type ReviewResult, type ReviewStatusLabel, @@ -56,10 +69,17 @@ export type { AssetStig, AssetUpdateInput, CollectionStig, + DeleteReviewHistoryInput, + MetricsSummaryAggAsset, + MetricsSummaryAggCollection, + MetricsSummaryAggStig, Review, ReviewBatchInput, ReviewBatchResponse, ReviewBatchResponseDryRun, + ReviewHistoryAsset, + ReviewHistoryFilters, + ReviewHistoryStats, ReviewPutInput, ReviewResult, ReviewStatusLabel, @@ -82,6 +102,19 @@ export const QUERY_KEYS = { ['reviews', cid, aid] as const, review: (cid: string, aid: string, ruleId: string) => ['review', cid, aid, ruleId] as const, + metricsCollection: (cid: string) => + ['collection', cid, 'metrics', 'collection'] as const, + metricsAsset: (cid: string) => + ['collection', cid, 'metrics', 'asset'] as const, + metricsStig: (cid: string) => + ['collection', cid, 'metrics', 'stig'] as const, + reviewHistory: (cid: string, filters?: ReviewHistoryFilters) => + ['collection', cid, 'review-history', filters ?? {}] as const, + reviewHistoryStats: ( + cid: string, + params?: { projection?: 'asset' } & ReviewHistoryFilters, + ) => + ['collection', cid, 'review-history-stats', params ?? {}] as const, } as const export function useAppInfo(): UseQueryResult { @@ -357,3 +390,89 @@ export function useReviewBatch(): UseMutationResult< }, }) } + +// ---- Metrics (M18e) ----------------------------------------------------- + +export function useMetricsCollection( + collectionId: string | undefined, +): UseQueryResult { + return useQuery({ + queryKey: collectionId + ? QUERY_KEYS.metricsCollection(collectionId) + : ['metrics', 'noop'], + queryFn: () => fetchMetricsSummaryCollection(collectionId as string), + enabled: Boolean(collectionId), + }) +} + +export function useMetricsByAsset( + collectionId: string | undefined, +): UseQueryResult { + return useQuery({ + queryKey: collectionId + ? QUERY_KEYS.metricsAsset(collectionId) + : ['metrics', 'asset', 'noop'], + queryFn: () => fetchMetricsSummaryByAsset(collectionId as string), + enabled: Boolean(collectionId), + }) +} + +export function useMetricsByStig( + collectionId: string | undefined, +): UseQueryResult { + return useQuery({ + queryKey: collectionId + ? QUERY_KEYS.metricsStig(collectionId) + : ['metrics', 'stig', 'noop'], + queryFn: () => fetchMetricsSummaryByStig(collectionId as string), + enabled: Boolean(collectionId), + }) +} + +// ---- Review History (M18e) ---------------------------------------------- + +export function useReviewHistory( + collectionId: string | undefined, + filters?: ReviewHistoryFilters, +): UseQueryResult { + return useQuery({ + queryKey: collectionId + ? QUERY_KEYS.reviewHistory(collectionId, filters) + : ['review-history', 'noop'], + queryFn: () => fetchReviewHistory(collectionId as string, filters), + enabled: Boolean(collectionId), + }) +} + +export function useReviewHistoryStats( + collectionId: string | undefined, + params?: { projection?: 'asset' } & ReviewHistoryFilters, +): UseQueryResult { + return useQuery({ + queryKey: collectionId + ? QUERY_KEYS.reviewHistoryStats(collectionId, params) + : ['review-history-stats', 'noop'], + queryFn: () => fetchReviewHistoryStats(collectionId as string, params), + enabled: Boolean(collectionId), + }) +} + +export function useDeleteReviewHistory(): UseMutationResult< + { HistoryEntriesDeleted: number }, + Error, + { collectionId: string; input: DeleteReviewHistoryInput } +> { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ collectionId, input }) => + deleteReviewHistory(collectionId, input), + onSuccess: (_data, { collectionId }) => { + void qc.invalidateQueries({ + queryKey: ['collection', collectionId, 'review-history'], + }) + void qc.invalidateQueries({ + queryKey: ['collection', collectionId, 'review-history-stats'], + }) + }, + }) +} diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index d59ccae..7234716 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -5,6 +5,7 @@ export { apiClient } from './client' export type { paths, components, operations } from './client' +import { getAccessTokenForClient } from '../auth/access-token' import { apiClient } from './client' /** @@ -485,3 +486,402 @@ export async function postReviewBatch( | ReviewBatchResponse | ReviewBatchResponseDryRun } + +// ---- Metrics (M18e) ----------------------------------------------------- +// +// The metrics-summary surface is a family of GET endpoints under +// /collections/{cid}/metrics/summary[/{aggregate}]. Each returns a +// MetricsSummaryAgg* shape from docs/openapi/stig-manager.yaml. +// `MetricsSummary` itself (the inner `metrics` object) is identical +// across aggregates — the outer envelope carries the aggregation +// dimension (collection-level totals, per-asset, per-stig, per-label). + +/** Counters keyed by severity (low/medium/high). */ +export type MetricsBySeverity = { high: number; low: number; medium: number } + +/** Counters keyed by review status. */ +export type MetricsStatusCounts = { + saved: number + submitted: number + accepted: number + rejected: number +} + +/** Counters keyed by review result for the summary aggregates. */ +export type MetricsResultCounts = { + fail: number + notapplicable: number + other: number + pass: number +} + +/** Common per-aggregate metrics block returned by all summary endpoints. */ +export type MetricsSummary = { + metrics: { + assessed: number + assessedBySeverity: MetricsBySeverity + assessments: number + assessmentsBySeverity: MetricsBySeverity + findings: MetricsBySeverity + maxTouchTs?: string | null + maxTs?: string | null + minTs?: string | null + results: MetricsResultCounts + statuses: MetricsStatusCounts + } +} + +/** Aggregated metrics for the entire Collection. */ +export type MetricsSummaryAggCollection = MetricsSummary & { + collectionId: string + name: string + assets: number + checklists: number + stigs: number +} + +/** Per-asset metrics row. */ +export type MetricsSummaryAggAsset = MetricsSummary & { + assetId: string + name: string + benchmarkIds: string[] + labels?: Array<{ labelId?: string; name?: string; color?: string | null }> +} + +/** Per-STIG metrics row. */ +export type MetricsSummaryAggStig = MetricsSummary & { + benchmarkId: string + title?: string + assets: number + collections?: number + ruleCount?: number + revisionStr?: string + revisionDate?: string | null +} + +/** Per-label metrics row. */ +export type MetricsSummaryAggLabel = MetricsSummary & { + labelId: string | null + name: string | null + assets: number +} + +export async function fetchMetricsSummaryCollection( + collectionId: string, +): Promise { + const result = await apiClient.GET( + '/collections/{collectionId}/metrics/summary/collection', + { params: { path: { collectionId } } }, + ) + if (!result.response.ok || !result.data) { + throw new Error(`metrics: HTTP ${result.response.status}`) + } + return result.data as unknown as MetricsSummaryAggCollection +} + +export async function fetchMetricsSummaryByAsset( + collectionId: string, +): Promise { + const result = await apiClient.GET( + '/collections/{collectionId}/metrics/summary/asset', + { params: { path: { collectionId } } }, + ) + if (!result.response.ok || !result.data) { + throw new Error(`metrics by asset: HTTP ${result.response.status}`) + } + return result.data as unknown as MetricsSummaryAggAsset[] +} + +export async function fetchMetricsSummaryByStig( + collectionId: string, +): Promise { + const result = await apiClient.GET( + '/collections/{collectionId}/metrics/summary/stig', + { params: { path: { collectionId } } }, + ) + if (!result.response.ok || !result.data) { + throw new Error(`metrics by stig: HTTP ${result.response.status}`) + } + return result.data as unknown as MetricsSummaryAggStig[] +} + +// ---- Review History (M18e) ---------------------------------------------- + +export type ReviewHistoryEntry = { + ts: string + touchTs?: string + result: ReviewResult + detail?: string + comment?: string + ruleId?: string + status?: { label?: ReviewStatusLabel; text?: string | null } + userId?: string + username?: string + autoResult?: boolean +} + +export type ReviewHistoryRule = { + ruleId: string + history: ReviewHistoryEntry[] +} + +export type ReviewHistoryAsset = { + assetId: string + reviewHistories: ReviewHistoryRule[] +} + +export type ReviewHistoryStats = { + collectionHistoryEntryCount: number + oldestHistoryEntryDate: string + assetHistoryEntryCounts?: Array<{ + assetId: string + historyEntryCount?: number + oldestHistoryEntry?: string | null + }> +} + +export type ReviewHistoryFilters = { + assetId?: string + ruleId?: string + status?: ReviewStatusLabel + startDate?: string + endDate?: string +} + +export async function fetchReviewHistory( + collectionId: string, + filters?: ReviewHistoryFilters, +): Promise { + const query: Record = {} + if (filters?.assetId) query['assetId'] = filters.assetId + if (filters?.ruleId) query['ruleId'] = filters.ruleId + if (filters?.status) query['status'] = filters.status + if (filters?.startDate) query['startDate'] = filters.startDate + if (filters?.endDate) query['endDate'] = filters.endDate + const result = await apiClient.GET( + '/collections/{collectionId}/review-history', + { params: { path: { collectionId }, query: query as never } }, + ) + if (!result.response.ok || !result.data) { + throw new Error(`review history: HTTP ${result.response.status}`) + } + return result.data as unknown as ReviewHistoryAsset[] +} + +export async function fetchReviewHistoryStats( + collectionId: string, + params?: { projection?: 'asset' } & ReviewHistoryFilters, +): Promise { + const query: Record = {} + if (params?.projection) query['projection'] = params.projection + if (params?.assetId) query['assetId'] = params.assetId + if (params?.ruleId) query['ruleId'] = params.ruleId + if (params?.status) query['status'] = params.status + if (params?.startDate) query['startDate'] = params.startDate + if (params?.endDate) query['endDate'] = params.endDate + const result = await apiClient.GET( + '/collections/{collectionId}/review-history/stats', + { params: { path: { collectionId }, query: query as never } }, + ) + if (!result.response.ok || !result.data) { + throw new Error(`review history stats: HTTP ${result.response.status}`) + } + return result.data as unknown as ReviewHistoryStats +} + +export type DeleteReviewHistoryInput = { + retentionDate?: string + assetId?: string +} + +export async function deleteReviewHistory( + collectionId: string, + input: DeleteReviewHistoryInput, +): Promise<{ HistoryEntriesDeleted: number }> { + const query: Record = {} + if (input.retentionDate) query['retentionDate'] = input.retentionDate + if (input.assetId) query['assetId'] = input.assetId + const result = await apiClient.DELETE( + '/collections/{collectionId}/review-history', + { params: { path: { collectionId }, query: query as never } }, + ) + if (!result.response.ok || !result.data) { + throw new Error(`delete review history: HTTP ${result.response.status}`) + } + return result.data as unknown as { HistoryEntriesDeleted: number } +} + +// ---- Exports (M18e) ----------------------------------------------------- +// +// Archive + POAM endpoints return raw binary streams (application/zip +// or .xlsx). openapi-fetch is configured for JSON, so we drop down to +// plain fetch() with the bearer token and trigger a browser download +// from the resulting Blob. Errors are surfaced as JSON if the server +// produced an envelope; otherwise we surface the status text. + +const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '/api' + +async function fetchBlob( + method: 'GET' | 'POST', + path: string, + init: { body?: unknown; query?: Record } = {}, +): Promise<{ blob: Blob; filename: string }> { + const url = new URL(`${API_BASE}${path}`, window.location.origin) + for (const [k, v] of Object.entries(init.query ?? {})) { + if (v !== '' && v !== undefined && v !== null) url.searchParams.set(k, v) + } + const headers: Record = {} + const token = getAccessTokenForClient() + if (token) headers['Authorization'] = `Bearer ${token}` + let body: BodyInit | undefined + if (init.body !== undefined) { + headers['Content-Type'] = 'application/json' + body = JSON.stringify(init.body) + } + const resp = await fetch(url.toString(), { + method, + headers, + body, + credentials: 'include', + }) + if (!resp.ok) { + let detail = '' + try { + detail = (await resp.text()).slice(0, 256) + } catch { + // ignore body-read failure; we still have status + } + throw new Error( + `${method} ${path}: HTTP ${resp.status}${detail ? ` — ${detail}` : ''}`, + ) + } + const filename = extractFilename(resp.headers.get('Content-Disposition')) + const blob = await resp.blob() + return { blob, filename } +} + +function extractFilename(disposition: string | null): string { + if (!disposition) return 'download' + // RFC 6266 — prefer filename* (UTF-8 encoded) when present. + const star = /filename\*=UTF-8''([^;]+)/i.exec(disposition) + if (star && star[1]) { + try { + return decodeURIComponent(star[1]) + } catch { + return star[1] + } + } + const plain = /filename="?([^";]+)"?/i.exec(disposition) + if (plain && plain[1]) return plain[1] + return 'download' +} + +/** Trigger a browser download for a Blob from JS without leaving the SPA. */ +export function downloadBlob(blob: Blob, filename: string): void { + const href = URL.createObjectURL(blob) + try { + const a = document.createElement('a') + a.href = href + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + } finally { + // Browsers hold the blob until revocation; defer slightly so the + // download has a chance to start. + setTimeout(() => URL.revokeObjectURL(href), 30_000) + } +} + +/** + * Body of POST /archive/ckl|cklb|xccdf. Each entry pins an Asset and + * (optionally) the list of STIG benchmarks to include for that asset. + * Per the schema, omitting `stigs` requests the default revisions of + * every benchmark mapped to the asset and visible to the caller. + */ +export type AssetStigSelection = { + assetId: string + stigs?: Array +} + +export type CklMode = 'mono' | 'multi' + +export async function downloadCklArchive( + collectionId: string, + selections: AssetStigSelection[], + mode: CklMode = 'mono', +): Promise { + const { blob, filename } = await fetchBlob( + 'POST', + `/collections/${encodeURIComponent(collectionId)}/archive/ckl`, + { body: selections, query: { mode } }, + ) + downloadBlob(blob, filename || `collection-${collectionId}-ckl.zip`) +} + +export async function downloadCklbArchive( + collectionId: string, + selections: AssetStigSelection[], + mode: CklMode = 'mono', +): Promise { + const { blob, filename } = await fetchBlob( + 'POST', + `/collections/${encodeURIComponent(collectionId)}/archive/cklb`, + { body: selections, query: { mode } }, + ) + downloadBlob(blob, filename || `collection-${collectionId}-cklb.zip`) +} + +export async function downloadXccdfArchive( + collectionId: string, + selections: AssetStigSelection[], +): Promise { + const { blob, filename } = await fetchBlob( + 'POST', + `/collections/${encodeURIComponent(collectionId)}/archive/xccdf`, + { body: selections }, + ) + downloadBlob(blob, filename || `collection-${collectionId}-xccdf.zip`) +} + +// ---- POAM (M18e) -------------------------------------------------------- + +export type PoamAggregator = 'groupId' | 'ruleId' +export type PoamFormat = 'emass' | 'mccast' + +export type PoamInput = { + aggregator?: PoamAggregator + format?: PoamFormat + acceptedOnly?: boolean + benchmarkId?: string + assetId?: string + date?: string + office?: string + status?: string + mccastPackageId?: string + mccastAuthName?: string +} + +export async function downloadPoam( + collectionId: string, + input: PoamInput = {}, +): Promise { + const query: Record = {} + if (input.aggregator) query['aggregator'] = input.aggregator + if (input.format) query['format'] = input.format + if (input.acceptedOnly) query['acceptedOnly'] = 'true' + if (input.benchmarkId) query['benchmarkId'] = input.benchmarkId + if (input.assetId) query['assetId'] = input.assetId + if (input.date) query['date'] = input.date + if (input.office) query['office'] = input.office + if (input.status) query['status'] = input.status + if (input.mccastPackageId) + query['mccastPackageId'] = input.mccastPackageId + if (input.mccastAuthName) query['mccastAuthName'] = input.mccastAuthName + const { blob, filename } = await fetchBlob( + 'GET', + `/collections/${encodeURIComponent(collectionId)}/poam`, + { query }, + ) + downloadBlob(blob, filename || `poam-${collectionId}.xlsx`) +} From 89c319a2a88ba500577edb7f3131b9c74b315075 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 04:02:43 +0000 Subject: [PATCH 2/2] fix(history): spread order so parent ruleId wins over entry field Co-Authored-By: Bryce Anglin --- web/src/app/pages/collections/history-tab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/pages/collections/history-tab.tsx b/web/src/app/pages/collections/history-tab.tsx index a6d3f88..2f3d75f 100644 --- a/web/src/app/pages/collections/history-tab.tsx +++ b/web/src/app/pages/collections/history-tab.tsx @@ -346,9 +346,9 @@ function HistoryList({ rows }: { rows: ReviewHistoryAsset[] }) { const flat = rows.flatMap((asset) => asset.reviewHistories.flatMap((rule) => rule.history.map((h) => ({ + ...h, assetId: asset.assetId, ruleId: rule.ruleId, - ...h, })), ), )