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
@@ -0,0 +1,171 @@
import { useMemo } from 'react';
import { PieChart, Pie, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, LabelList } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
// Severity palette stays within the design's semantic tokens: --destructive
// (critical), --warning (high), a muted --warning (medium), --muted-foreground
// (low). No new chart hue.
const SEVERITY_CONFIG = {
critical: { label: 'Critical', color: 'var(--destructive)' },
high: { label: 'High', color: 'var(--warning)' },
medium: { label: 'Medium', color: 'color-mix(in oklch, var(--warning) 55%, var(--muted))' },
low: { label: 'Low', color: 'var(--muted-foreground)' },
} satisfies ChartConfig;
// The trend and top-exposed charts both plot only the Critical + High slots.
const CRITICAL_HIGH_CONFIG = {
critical: SEVERITY_CONFIG.critical,
high: SEVERITY_CONFIG.high,
} satisfies ChartConfig;
function EmptyChart({ label, height }: { label: string; height: number }) {
return (
<div className="flex items-center justify-center text-xs text-stat-subtitle" style={{ height }}>
{label}
</div>
);
}
/** Donut of total findings by severity across the node's scanned images. */
export function SeverityDonutChart({ summaries }: { summaries: ScanSummary[] }) {
const data = useMemo(() => {
const totals = { critical: 0, high: 0, medium: 0, low: 0 };
for (const s of summaries) {
totals.critical += s.critical;
totals.high += s.high;
totals.medium += s.medium;
totals.low += s.low;
}
return (['critical', 'high', 'medium', 'low'] as const)
.map((k) => ({ key: k, label: SEVERITY_CONFIG[k].label, value: totals[k], fill: `var(--color-${k})` }))
.filter((d) => d.value > 0);
}, [summaries]);
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) return <EmptyChart label="No findings to chart" height={220} />;
return (
<ChartContainer config={SEVERITY_CONFIG} className="h-[220px] w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
<Pie data={data} dataKey="value" nameKey="label" innerRadius={55} outerRadius={85} strokeWidth={2} paddingAngle={2} />
</PieChart>
</ChartContainer>
);
}
/** Stacked area of Critical + High findings by scan-day (days with no scans are omitted). */
export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) {
if (trend.length === 0) return <EmptyChart label="No scan history yet" height={220} />;
const fmtDate = (d: string) => d.slice(5); // MM-DD
return (
<ChartContainer config={CRITICAL_HIGH_CONFIG} className="h-[220px] w-full">
<AreaChart data={trend} margin={{ left: 4, right: 8, top: 8 }}>
<defs>
<linearGradient id="riskHigh" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-high)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-high)" stopOpacity={0.02} />
</linearGradient>
<linearGradient id="riskCritical" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-critical)" stopOpacity={0.4} />
<stop offset="95%" stopColor="var(--color-critical)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="date" tickFormatter={fmtDate} tickLine={false} axisLine={false} fontSize={10} minTickGap={24} />
<YAxis tickLine={false} axisLine={false} fontSize={10} width={28} allowDecimals={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area dataKey="high" stackId="risk" stroke="var(--color-high)" fill="url(#riskHigh)" strokeWidth={1.5} />
<Area dataKey="critical" stackId="risk" stroke="var(--color-critical)" fill="url(#riskCritical)" strokeWidth={1.5} />
</AreaChart>
</ChartContainer>
);
}
interface TopImageDatum { name: string; critical: number; high: number; scanId: number }
/** Horizontal stacked bars of the top images by Critical+High; click opens the scan. */
export function TopExposedImagesChart({
summaries,
onInspect,
}: {
summaries: ScanSummary[];
onInspect: (scanId: number) => void;
}) {
const data: TopImageDatum[] = useMemo(
() =>
summaries
.filter((s) => !s.image_ref.startsWith('stack:') && s.critical + s.high > 0)
.sort((a, b) => b.critical + b.high - (a.critical + a.high))
.slice(0, 6)
.map((s) => ({
name: s.image_ref.length > 28 ? `${s.image_ref.slice(-27)}` : s.image_ref,
critical: s.critical,
high: s.high,
scanId: s.scan_id,
})),
[summaries],
);
if (data.length === 0) return <EmptyChart label="No exposed images" height={220} />;
const handleBarClick = (d: unknown) => {
const dd = d as TopImageDatum;
if (dd?.scanId != null) onInspect(dd.scanId);
};
return (
<ChartContainer config={CRITICAL_HIGH_CONFIG} className="h-[220px] w-full">
<BarChart data={data} layout="vertical" margin={{ left: 8, right: 16 }}>
<XAxis type="number" hide allowDecimals={false} />
<YAxis type="category" dataKey="name" width={150} tickLine={false} axisLine={false} fontSize={10} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="critical" stackId="r" fill="var(--color-critical)" radius={[2, 0, 0, 2]} className="cursor-pointer" onClick={handleBarClick} />
<Bar dataKey="high" stackId="r" fill="var(--color-high)" radius={[0, 2, 2, 0]} className="cursor-pointer" onClick={handleBarClick} />
</BarChart>
</ChartContainer>
);
}
/** Vertical bars comparing the three finding types. */
export function FindingsByTypeChart({ summaries }: { summaries: ScanSummary[] }) {
const data = useMemo(() => {
let vulnerabilities = 0;
let secrets = 0;
let misconfigs = 0;
for (const s of summaries) {
vulnerabilities += s.total;
secrets += s.secret_count;
misconfigs += s.misconfig_count;
}
return [
{ type: 'Vulnerabilities', value: vulnerabilities, fill: 'var(--brand)' },
{ type: 'Secrets', value: secrets, fill: 'var(--destructive)' },
{ type: 'Misconfigs', value: misconfigs, fill: 'var(--warning)' },
];
}, [summaries]);
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) return <EmptyChart label="No findings to chart" height={220} />;
const config = {
value: { label: 'Findings' },
} satisfies ChartConfig;
return (
<ChartContainer config={config} className="h-[220px] w-full">
<BarChart data={data} margin={{ left: 4, right: 8, top: 16 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="type" tickLine={false} axisLine={false} fontSize={10} />
<YAxis tickLine={false} axisLine={false} fontSize={10} width={28} allowDecimals={false} />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} maxBarSize={64}>
<LabelList dataKey="value" position="top" className="fill-stat-subtitle" fontSize={10} />
</Bar>
</BarChart>
</ChartContainer>
);
}