mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 23:56:39 +00:00
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:
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user