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,41 @@
/**
* useImageScan triggers a scan and polls to completion. This covers the
* start-failure path: a non-OK scan POST surfaces an error toast (with the HTTP
* status, not a confusing JSON parse error) and clears the in-flight ref, rather
* than spinning until the poll timeout.
*/
import { it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), loading: vi.fn(() => 'id'), dismiss: vi.fn() },
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useImageScan } from '../useImageScan';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockedFetch.mockReset();
mockedToast.error.mockReset();
});
it('toasts the HTTP status and clears the in-flight ref when the scan POST fails', async () => {
mockedFetch.mockResolvedValue({ ok: false, status: 503, json: async () => ({}) } as unknown as Response);
const onComplete = vi.fn();
const onSummaries = vi.fn();
const { result } = renderHook(() => useImageScan({ onComplete, onSummaries }));
await act(async () => {
await result.current.scanImage('nginx:1', ['vuln']);
});
await waitFor(() => expect(mockedToast.error).toHaveBeenCalledWith(expect.stringContaining('503')));
expect(onComplete).not.toHaveBeenCalled();
expect(result.current.scanningRef).toBeNull();
});
+114
View File
@@ -0,0 +1,114 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import type { ScanSummary, ScannerKind } from '@/types/security';
interface UseImageScanOptions {
/** Called with the finished scan's id (e.g. to open the detail sheet). */
onComplete: (scanId: number) => void;
/** Called with the refreshed image-summaries map after a scan completes. */
onSummaries: (summaries: Record<string, ScanSummary>) => void;
}
/**
* Triggers a Trivy scan for an image and polls until it finishes, then refreshes
* the image-summaries and reports the completed scan id. A new scan supersedes
* any in-flight poll, and the poll is abandoned (server-side scan keeps running)
* on unmount. Mirrors the Resources image-scan flow so the Security Images tab
* can scan without re-implementing it.
*/
export function useImageScan({ onComplete, onSummaries }: UseImageScanOptions) {
const [scanningRef, setScanningRef] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => abortRef.current?.abort(), []);
const scanImage = useCallback(
async (imageRef: string, scanners: ScannerKind[]) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const { signal } = controller;
setScanningRef(imageRef);
const loadingId = toast.loading(`Scanning ${imageRef}...`);
try {
const res = await apiFetch('/security/scan', {
method: 'POST',
body: JSON.stringify({ imageRef, force: true, scanners }),
signal,
});
// Check the HTTP status before parsing: a non-JSON error body (e.g. a
// proxy 502) would otherwise surface a confusing parse error instead of
// the real failure.
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error || `Failed to start scan (HTTP ${res.status})`);
}
const data = (await res.json()) as { scanId: number };
const scanId = data.scanId;
const deadline = Date.now() + 5 * 60 * 1000;
while (Date.now() < deadline) {
await new Promise<void>((resolve) => {
if (signal.aborted) { resolve(); return; }
const timer = setTimeout(resolve, 3000);
signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
});
if (signal.aborted) return;
const poll = await apiFetch(`/security/scans/${scanId}`, { signal });
if (signal.aborted) return;
if (!poll.ok) {
// A transient non-OK poll is retried, but a hard error (gone/auth)
// would otherwise masquerade as a 5-minute "timed out".
console.warn('[Security] scan status poll failed:', poll.status);
if (poll.status === 404 || poll.status === 401) {
throw new Error(`Scan status unavailable (HTTP ${poll.status})`);
}
continue;
}
const pollData = await poll.json();
if (signal.aborted) return;
if (pollData.status === 'in_progress') continue;
if (pollData.status === 'completed') {
toast.success(`Scan complete: ${pollData.total_vulnerabilities ?? 0} vulnerabilities found`);
onComplete(scanId);
const summariesRes = await apiFetch('/security/image-summaries', { signal });
if (signal.aborted) return;
if (summariesRes.ok) {
const summaries = await summariesRes.json();
if (signal.aborted) return;
onSummaries(summaries ?? {});
} else {
// The scan succeeded; only the summaries refresh failed. Keep the
// table from silently going stale by surfacing it.
console.warn('[Security] image-summaries refresh after scan failed:', summariesRes.status);
}
return;
}
// 'failed' or any unexpected/malformed status: never read as success.
throw new Error(pollData.error || `Scan failed (status: ${pollData.status ?? 'unknown'})`);
}
throw new Error('Scan timed out');
} catch (error) {
if (signal.aborted) {
// A deliberately cancelled poll is not an error, but keep a breadcrumb
// so a real failure racing the abort is not lost.
console.debug('Scan poll aborted', error);
return;
}
toast.error((error as Error)?.message || 'Scan failed');
} finally {
toast.dismiss(loadingId);
// Only the owning poll clears the shared state; a superseded poll leaves
// it to the scan that replaced it.
if (abortRef.current === controller) {
abortRef.current = null;
setScanningRef(null);
}
}
},
[onComplete, onSummaries],
);
return { scanningRef, scanImage };
}