+ {/* items-start: each card keeps its natural height so the fixed-height chart
+ card never stretches to a taller exploit table (which left dead space
+ under the chart). The exploit-risk table owns its own card chrome. */}
+
+
diff --git a/frontend/src/components/security/SecurityCharts.test.tsx b/frontend/src/components/security/SecurityCharts.test.tsx
index ce724ab3..dd723722 100644
--- a/frontend/src/components/security/SecurityCharts.test.tsx
+++ b/frontend/src/components/security/SecurityCharts.test.tsx
@@ -36,6 +36,7 @@ vi.mock('recharts', async () => {
YAxis: stub('YAxis'),
ZAxis: stub('ZAxis'),
CartesianGrid: stub('CartesianGrid'),
+ Label: stub('Label'),
LabelList: stub('LabelList'),
ReferenceLine: stub('ReferenceLine'),
};
@@ -102,6 +103,18 @@ describe('ActionPostureChart', () => {
});
describe('TopExploitRiskList', () => {
+ const rowsOf = (container: HTMLElement) => [...container.querySelectorAll('li[role="button"]')];
+
+ it('renders column headers for the table', () => {
+ const { container } = render(
+
,
+ );
+ expect(container.textContent).toContain('CVE');
+ expect(container.textContent).toContain('Image');
+ expect(container.textContent).toContain('EPSS');
+ expect(container.textContent).toContain('CVSS');
+ });
+
it('ranks KEV > high EPSS > unknown EPSS > low EPSS (assume automatable), and opens the scan', () => {
const items = [
finding({ vulnerability_id: 'CVE-LOW', cvss_score: 5, epss_score: 0.01, scan_id: 10 }),
@@ -111,14 +124,63 @@ describe('TopExploitRiskList', () => {
];
const onInspect = vi.fn();
const { container } = render(
);
- const buttons = [...container.querySelectorAll('button')];
- const order = buttons.map((b) => b.querySelector('.font-mono')?.textContent);
+ const rows = rowsOf(container);
+ const order = rows.map((r) => r.querySelector('.font-mono')?.textContent);
// Unknown-exploitability (CVE-UNK) outranks the evidenced-low one (CVE-LOW).
expect(order).toEqual(['CVE-KEV', 'CVE-EPSS', 'CVE-UNK', 'CVE-LOW']);
- fireEvent.click(buttons[0]);
+ fireEvent.click(rows[0]);
expect(onInspect).toHaveBeenCalledWith(11);
});
+ it('paginates beyond the page size and advances and rewinds pages', () => {
+ const items = Array.from({ length: 9 }, (_, i) =>
+ finding({ vulnerability_id: `CVE-${i}`, cvss_score: 9 - i * 0.1, epss_score: 0.5, scan_id: i }),
+ );
+ const { container } = render(
);
+ expect(rowsOf(container)).toHaveLength(8);
+ expect(container.textContent).toContain('1 / 2');
+ const prev = container.querySelector('button[aria-label="Previous page"]') as HTMLButtonElement;
+ const next = container.querySelector('button[aria-label="Next page"]') as HTMLButtonElement;
+ expect(prev.disabled).toBe(true); // disabled on the first page
+ fireEvent.click(next);
+ expect(rowsOf(container)).toHaveLength(1);
+ expect(container.textContent).toContain('2 / 2');
+ fireEvent.click(prev);
+ expect(rowsOf(container)).toHaveLength(8);
+ expect(container.textContent).toContain('1 / 2');
+ });
+
+ it('does not collide keys or accumulate rows when CVE/scan pairs recur across a page boundary', () => {
+ // The same scan_id + vulnerability_id recurs across packages/images, so the
+ // old composite key was non-unique and React duplicated rows when paging.
+ // Position-based keys keep every row unique: no duplicate-key warning, and
+ // each page renders exactly its slice.
+ const items = Array.from({ length: 20 }, (_, i) =>
+ finding({ vulnerability_id: 'CVE-DUP', scan_id: 1, image_ref: `img-${i}:1`, cvss_score: 7, epss_score: 0.5 }),
+ );
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ const { container } = render(
);
+ expect(rowsOf(container)).toHaveLength(8);
+ const next = container.querySelector('button[aria-label="Next page"]') as HTMLButtonElement;
+ fireEvent.click(next);
+ fireEvent.click(next);
+ expect(rowsOf(container)).toHaveLength(4); // page 3 of 20 = 4 rows, not an accumulation
+ expect(container.textContent).toContain('3 / 3');
+ const keyWarnings = errSpy.mock.calls.filter((c) => /same key|unique "key"/i.test(String(c[0])));
+ expect(keyWarnings).toEqual([]);
+ } finally {
+ errSpy.mockRestore();
+ }
+ });
+
+ it('shows no pager at or below the page size', () => {
+ const items = Array.from({ length: 8 }, (_, i) => finding({ vulnerability_id: `CVE-${i}`, cvss_score: 9, epss_score: 0.5, scan_id: i }));
+ const { container } = render(
);
+ expect(rowsOf(container)).toHaveLength(8);
+ expect(container.querySelector('button[aria-label="Next page"]')).toBeNull();
+ });
+
it('shows the severity-ranked hint when no intel is present', () => {
const { container } = render(
,
diff --git a/frontend/src/components/security/SecurityCharts.tsx b/frontend/src/components/security/SecurityCharts.tsx
index 3d9793fa..71614b04 100644
--- a/frontend/src/components/security/SecurityCharts.tsx
+++ b/frontend/src/components/security/SecurityCharts.tsx
@@ -1,8 +1,11 @@
+import { useMemo, useState } from 'react';
import {
AreaChart, Area, BarChart, Bar, Cell, ScatterChart, Scatter,
- XAxis, YAxis, ZAxis, CartesianGrid, LabelList, ReferenceLine, Tooltip,
+ XAxis, YAxis, ZAxis, CartesianGrid, Label, LabelList, ReferenceLine, Tooltip,
} from 'recharts';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
+import { Button } from '@/components/ui/button';
import { useChartStyle, type ChartStyle } from '@/hooks/use-theme';
import { cn } from '@/lib/utils';
import type { SecurityRiskTrendPoint, SecurityOverview, ExploitIntelFinding } from '@/types/security';
@@ -152,7 +155,16 @@ function shortImage(ref: string): string {
return ref.length > 30 ? `…${ref.slice(-29)}` : ref;
}
-/** Ranked list of the highest exploit-risk actionable findings; row opens the scan. */
+const EXPLOIT_PAGE_SIZE = 8;
+
+// Header and body rows share this template so columns stay aligned. `max-md:min-w`
+// keeps the table from crushing its columns below md, where the card scrolls
+// horizontally instead; desktop is untouched by the `max-md:` prefix.
+const EXPLOIT_GRID = 'grid-cols-[10px_minmax(0,1.4fr)_minmax(0,1fr)_56px_52px] max-md:min-w-[480px]';
+
+/** Ranked, paginated table of the highest exploit-risk actionable findings; a row opens the scan.
+ * Renders its own card chrome (header + pagination + column headers) so the Overview reads as a
+ * table, mirroring the dashboard Stack-health table. */
export function TopExploitRiskList({
items,
onInspect,
@@ -160,55 +172,94 @@ export function TopExploitRiskList({
items: ExploitIntelFinding[];
onInspect: (scanId: number) => void;
}) {
- if (items.length === 0) return
;
- const ranked = [...items].sort(exploitRank).slice(0, 8);
+ const [page, setPage] = useState(0);
+ const ranked = useMemo(() => [...items].sort(exploitRank), [items]);
const anyIntel = items.some((i) => i.epss_score !== null || i.kev);
+ const totalPages = Math.max(1, Math.ceil(ranked.length / EXPLOIT_PAGE_SIZE));
+ const safePage = Math.min(page, totalPages - 1);
+ const pageItems = ranked.slice(safePage * EXPLOIT_PAGE_SIZE, (safePage + 1) * EXPLOIT_PAGE_SIZE);
+ const needsPagination = ranked.length > EXPLOIT_PAGE_SIZE;
+
return (
-
-
- {ranked.map((f) => (
-
onInspect(f.scan_id)}
- className="flex w-full items-center gap-2 border-b border-hairline py-2 text-left last:border-b-0 hover:bg-glass-highlight"
- >
-
-
- {f.vulnerability_id}
- {shortImage(f.image_ref)}
-
-
- {f.kev && (
- KEV
- )}
- {f.epss_score !== null && (
- {Math.round(f.epss_score * 100)}%
- )}
- {!f.kev && f.epss_score === null && (
-
- EPSS n/a
-
- )}
- {f.cvss_score !== null && (
- CVSS {f.cvss_score}
- )}
-
-
- ))}
+
+
+
Top exploit-risk findings
+ {needsPagination && (
+
+ setPage(safePage - 1)} aria-label="Previous page">
+
+
+ {safePage + 1} / {totalPages}
+ = totalPages - 1} onClick={() => setPage(safePage + 1)} aria-label="Next page">
+
+
+
+ )}
- {!anyIntel && (
-
- Ranked by severity. Enable exploit intelligence and re-scan to rank by known-exploited and EPSS.
-
+
+ {ranked.length === 0 ? (
+
+ No actionable Critical or High findings
+
+ ) : (
+ <>
+
+
+ CVE
+ Image
+ EPSS
+ CVSS
+
+
+ {pageItems.map((f, i) => (
+ // Key by absolute rank position: the same CVE can recur across
+ // packages/images with an identical scan_id + vulnerability_id, so
+ // those fields are not unique. Position in the sorted list is.
+ onInspect(f.scan_id)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onInspect(f.scan_id);
+ }
+ }}
+ className={`grid ${EXPLOIT_GRID} cursor-pointer items-center gap-2 px-4 py-2 transition-colors hover:bg-glass-highlight`}
+ >
+
+
+ {f.vulnerability_id}
+ {f.kev && (
+ KEV
+ )}
+
+ {shortImage(f.image_ref)}
+
+ {f.epss_score !== null ? (
+ {Math.round(f.epss_score * 100)}%
+ ) : (
+ n/a
+ )}
+
+
+ {f.cvss_score !== null ? f.cvss_score : '-'}
+
+
+ ))}
+
+ {!anyIntel && (
+
+ Ranked by severity. Enable exploit intelligence and re-scan to rank by known-exploited and EPSS.
+
+ )}
+ >
)}
);
@@ -254,18 +305,26 @@ export function CvssEpssQuadrantChart({ items }: { items: ExploitIntelFinding[]
const otherPoints = plotted.filter((p) => !p.kev);
return (
+ // Fixed height, not flex-fill: a flex/grid-stretched ResponsiveContainer
+ // re-measures a content-driven height and grows on every render. The parent
+ // grid (OverviewTab) uses items-start so this card does not stretch to a
+ // taller neighbour, which is what previously left dead space under the chart.
-
-
+
+
+ >
+
+
+ tickLine={false} axisLine={false} fontSize={10} width={40}
+ >
+
+