feat: chart-led Security overview with sortable Images and History tables (#1364)

* feat: chart-led Security overview with sortable Images and History tables

Refine the Security page around the existing design system and add the
data the dashboard needs.

- Overview leads with four charts (30-day risk trend, severity donut, top
  exposed images, findings by type); the signal-rail counts become a
  secondary summary, and the scanner and deploy-enforcement posture follow.
- Images becomes a recessed table with search, a severity filter, sortable
  columns, a last-scan column, and inline scan actions; the findings cell is
  clickable into the scan sheet, and the per-row cursor tooltip is dropped
  where the columns already carry that information.
- Policies puts deploy-enforcement first, collapses the policy packs into an
  accordion, and uses the standard primary button for Add policy.
- Suppressions and acknowledgements move their titles and Add buttons outside
  the cards, matching the Fleet tab layout.
- History switches from the detail sheet to an inline table (search, sortable
  columns, two-scan compare, pagination); the now-unreachable scan-history
  overlay is removed.
- Add GET /api/security/overview/trend, a node-scoped daily critical/high
  rollup backing the risk-trend chart.
- Extract the shared image-scan hook and the severity classifier, and harden
  the overview data fetch so a malformed non-critical response can never read
  as a clean security state.

* fix: treat malformed Security responses as errors, not empty or clean states

Address an independent review of the data-fetch paths so a 200 with an
unexpected shape can never read as a benign "no findings" view.

- SecurityView: validate that the image-summaries body is a scan-summary map; an
  unexpected shape now sets the error state instead of an empty map. Isolate the
  trend fetch in its own self-catching promise so a transport failure on the
  non-critical chart can no longer poison the overview or summaries error state.
- useImageScan: only a "completed" poll counts as success (a malformed or unknown
  status now throws), and a failed post-scan summaries refresh is logged instead
  of silently dropped.
- HistoryTab: a 200 whose body lacks an items array is treated as an error, not
  an empty "no completed scans" list.
This commit is contained in:
Anso
2026-06-12 14:35:03 -04:00
committed by GitHub
parent 1b96f3b980
commit 3d39d856a3
31 changed files with 1570 additions and 931 deletions
+29 -26
View File
@@ -1,25 +1,24 @@
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
import { SEVERITY_BADGE_CLASSES, SEVERITY_DOT_CLASSES } from '@/lib/severityStyles';
import type { ScanSummary, VulnSeverity } from '@/types/security';
import { SEVERITY_BADGE_CLASSES, SEVERITY_DOT_CLASSES, getSeverityKey } from '@/lib/severityStyles';
import type { ScanSummary } from '@/types/security';
/**
* Severity pill for a scanned image's latest summary. Shows the highest
* severity (or "Clean") with a state dot and a cursor-follow tooltip carrying
* the last-scanned time and a severity breakdown. Shared by the Resources view
* and the Security page so the badge stays identical everywhere.
* severity (or "Clean"/"Findings") with a state dot. By default it carries a
* cursor-follow tooltip with the last-scanned time and a severity breakdown;
* pass `tooltip={false}` where those facts already have dedicated columns.
* Shared by the Resources view and the Security page so the badge stays
* identical everywhere.
*/
export function SeverityBadge({ summary, onClick }: { summary: ScanSummary; onClick: () => void }) {
// highest_severity is derived from vulnerabilities only, so a scan with
// secrets or misconfigurations but zero CVEs would otherwise read "Clean".
// Treat those as a non-clean "Findings" state.
const hasNonVulnFindings = (summary.secret_count ?? 0) > 0 || (summary.misconfig_count ?? 0) > 0;
const key: VulnSeverity | 'CLEAN' | 'FINDINGS' =
summary.highest_severity ?? (hasNonVulnFindings ? 'FINDINGS' : 'CLEAN');
export function SeverityBadge({ summary, onClick, tooltip = true }: { summary: ScanSummary; onClick: () => void; tooltip?: boolean }) {
const key = getSeverityKey(summary);
const hasNonVulnFindings = key === 'FINDINGS';
const label = key === 'CLEAN' ? 'Clean' : key === 'FINDINGS' ? 'Findings' : key;
const [relative, setRelative] = useState<string>('');
useEffect(() => {
if (!tooltip) return;
const compute = () => {
const scanAge = Math.round((Date.now() - summary.scanned_at) / 60000);
setRelative(
@@ -32,23 +31,27 @@ export function SeverityBadge({ summary, onClick }: { summary: ScanSummary; onCl
compute();
const id = setInterval(compute, 60000);
return () => clearInterval(id);
}, [summary.scanned_at]);
}, [summary.scanned_at, tooltip]);
const pill = (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium cursor-pointer hover:brightness-110 transition',
SEVERITY_BADGE_CLASSES[key],
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_CLASSES[key])} />
{label}
</button>
);
if (!tooltip) return pill;
return (
<CursorProvider>
<CursorContainer className="inline-flex">
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium cursor-pointer hover:brightness-110 transition',
SEVERITY_BADGE_CLASSES[key],
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_CLASSES[key])} />
{label}
</button>
</CursorContainer>
<CursorContainer className="inline-flex">{pill}</CursorContainer>
<Cursor>
<div className="h-2 w-2 rounded-full bg-brand" />
</Cursor>
@@ -51,3 +51,11 @@ it('renders "Findings" for a misconfig-only scan', () => {
render(<SeverityBadge summary={summary({ highest_severity: null, misconfig_count: 3 })} onClick={() => {}} />);
expect(screen.getByRole('button', { name: /Findings/ })).toBeInTheDocument();
});
it('renders the bare pill and fires onClick with tooltip disabled', async () => {
const onClick = vi.fn();
render(<SeverityBadge summary={summary({ highest_severity: 'HIGH', total: 1, high: 1 })} onClick={onClick} tooltip={false} />);
const btn = screen.getByRole('button', { name: /HIGH/ });
await userEvent.click(btn);
expect(onClick).toHaveBeenCalledOnce();
});