mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +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:
@@ -58,6 +58,40 @@ function seedFailed(imageRef: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Midnight (UTC) `daysAgo` days back, so seeded times stay within one calendar day. */
|
||||
function dayStartMs(daysAgo: number): number {
|
||||
const d = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
function seedCompleted(o: { imageRef: string; scannedAt: number; critical: number; high: number; nodeId?: number; status?: 'completed' | 'failed' }): void {
|
||||
db().createVulnerabilityScan({
|
||||
node_id: o.nodeId ?? 1,
|
||||
image_ref: o.imageRef,
|
||||
image_digest: `sha256:${o.imageRef}-${Math.random().toString(16).slice(2)}`,
|
||||
scanned_at: o.scannedAt,
|
||||
total_vulnerabilities: o.critical + o.high,
|
||||
critical_count: o.critical,
|
||||
high_count: o.high,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unknown_count: 0,
|
||||
fixable_count: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
scanners_used: 'vuln',
|
||||
highest_severity: o.critical > 0 ? 'CRITICAL' : o.high > 0 ? 'HIGH' : null,
|
||||
os_info: null,
|
||||
trivy_version: null,
|
||||
scan_duration_ms: null,
|
||||
triggered_by: 'manual',
|
||||
status: o.status ?? 'completed',
|
||||
error: o.status === 'failed' ? 'boom' : null,
|
||||
stack_context: null,
|
||||
});
|
||||
}
|
||||
|
||||
function seedPolicy(overrides: Partial<Omit<ScanPolicy, 'id' | 'created_at' | 'updated_at'>>): void {
|
||||
db().createScanPolicy({
|
||||
name: overrides.name ?? 'p',
|
||||
@@ -116,3 +150,33 @@ describe('countEligibleBlockPolicies (replica)', () => {
|
||||
expect(db().countEligibleBlockPolicies(1, 'replica', 'self-id')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDailyRiskTrend', () => {
|
||||
it('sums latest-per-image critical/high per day and orders days ascending', () => {
|
||||
const day1 = dayStartMs(3);
|
||||
const day2 = dayStartMs(2);
|
||||
// Day 1: imageA scanned twice; the later scan replaces the earlier one.
|
||||
seedCompleted({ imageRef: 'a:1', scannedAt: day1 + 3_600_000, critical: 5, high: 2 });
|
||||
seedCompleted({ imageRef: 'a:1', scannedAt: day1 + 7_200_000, critical: 3, high: 1 });
|
||||
seedCompleted({ imageRef: 'b:1', scannedAt: day1 + 3_600_000, critical: 1, high: 1 });
|
||||
// Day 2: a single image.
|
||||
seedCompleted({ imageRef: 'a:1', scannedAt: day2 + 3_600_000, critical: 0, high: 4 });
|
||||
|
||||
const trend = db().getDailyRiskTrend(1, 30);
|
||||
expect(trend).toHaveLength(2);
|
||||
expect(trend[0]).toMatchObject({ critical: 4, high: 2 }); // latest a (3,1) + b (1,1)
|
||||
expect(trend[1]).toMatchObject({ critical: 0, high: 4 });
|
||||
expect(trend[0].date < trend[1].date).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes other nodes and non-completed scans', () => {
|
||||
const day = dayStartMs(1);
|
||||
seedCompleted({ imageRef: 'a:1', scannedAt: day + 3_600_000, critical: 2, high: 1 });
|
||||
seedCompleted({ imageRef: 'other:1', scannedAt: day + 3_600_000, critical: 9, high: 9, nodeId: 2 });
|
||||
seedCompleted({ imageRef: 'failed:1', scannedAt: day + 3_600_000, critical: 7, high: 7, status: 'failed' });
|
||||
|
||||
const trend = db().getDailyRiskTrend(1, 30);
|
||||
expect(trend).toHaveLength(1);
|
||||
expect(trend[0]).toMatchObject({ critical: 2, high: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +139,38 @@ describe('GET /api/security/overview', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/security/overview/trend', () => {
|
||||
beforeEach(() => resetSecurity());
|
||||
|
||||
const dayStart = (daysAgo: number): number => {
|
||||
const d = new Date(Date.now() - daysAgo * DAY);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
};
|
||||
|
||||
it('returns ascending daily critical/high points, node-scoped and completed only', async () => {
|
||||
const d1 = dayStart(3);
|
||||
const d2 = dayStart(2);
|
||||
seedScan({ image_ref: 'a:1', scanned_at: d1 + 3_600_000, critical: 4, high: 2 });
|
||||
seedScan({ image_ref: 'a:1', scanned_at: d2 + 3_600_000, critical: 1, high: 5 });
|
||||
seedScan({ node_id: 2, image_ref: 'x:1', scanned_at: d2 + 3_600_000, critical: 9, high: 9 }); // other node
|
||||
seedScan({ image_ref: 'f:1', scanned_at: d2 + 3_600_000, critical: 7, high: 7, status: 'failed' }); // failed
|
||||
|
||||
const res = await request(app).get('/api/security/overview/trend').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.body[0]).toMatchObject({ critical: 4, high: 2 });
|
||||
expect(res.body[1]).toMatchObject({ critical: 1, high: 5 });
|
||||
expect(res.body[0].date < res.body[1].date).toBe(true);
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/security/overview/trend');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/security/policy-packs', () => {
|
||||
it('returns the 5 default packs with fully-formed rules (auth-only)', async () => {
|
||||
const res = await request(app).get('/api/security/policy-packs').set('Cookie', adminCookie);
|
||||
|
||||
@@ -530,6 +530,22 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
}
|
||||
});
|
||||
|
||||
// Daily Critical/High risk trend for the Security overview chart. Auth-only
|
||||
// (Community), node-scoped. ?days clamps to 1..365 in the DB layer.
|
||||
securityRouter.get('/overview/trend', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const days = req.query.days ? Number(req.query.days) : 30;
|
||||
const trend = DatabaseService.getInstance().getDailyRiskTrend(
|
||||
req.nodeId,
|
||||
Number.isFinite(days) ? days : 30,
|
||||
);
|
||||
res.json(trend);
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to build risk trend:', error);
|
||||
res.status(500).json({ error: 'Failed to build risk trend' });
|
||||
}
|
||||
});
|
||||
|
||||
// Static, read-only policy-pack catalog. Auth-only (Community), no DB, no
|
||||
// enforcement. The frontend fetches this with localOnly so the global catalog
|
||||
// is available regardless of which node is active.
|
||||
|
||||
@@ -4515,6 +4515,46 @@ export class DatabaseService {
|
||||
).cnt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily Critical/High totals for the node over the last `days` days, for the
|
||||
* Security overview risk-trend chart. For each calendar day with scans, takes
|
||||
* the latest completed scan per image (so a re-scan replaces, not adds) and
|
||||
* sums the critical and high counts across images. Days with no scans are
|
||||
* omitted from the result.
|
||||
*/
|
||||
public getDailyRiskTrend(
|
||||
nodeId: number,
|
||||
days = 30,
|
||||
): Array<{ date: string; critical: number; high: number }> {
|
||||
const window = Math.max(1, Math.min(days, 365));
|
||||
const cutoffMs = Date.now() - window * 24 * 60 * 60 * 1000;
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`WITH daily_latest AS (
|
||||
SELECT
|
||||
DATE(scanned_at / 1000, 'unixepoch') AS day,
|
||||
image_ref,
|
||||
critical_count,
|
||||
high_count,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY DATE(scanned_at / 1000, 'unixepoch'), image_ref
|
||||
ORDER BY scanned_at DESC
|
||||
) AS rn
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed' AND scanned_at >= ?
|
||||
)
|
||||
SELECT day,
|
||||
SUM(critical_count) AS critical,
|
||||
SUM(high_count) AS high
|
||||
FROM daily_latest
|
||||
WHERE rn = 1
|
||||
GROUP BY day
|
||||
ORDER BY day ASC`,
|
||||
)
|
||||
.all(nodeId, cutoffMs) as Array<{ day: string; critical: number; high: number }>;
|
||||
return rows.map((r) => ({ date: r.day, critical: r.critical ?? 0, high: r.high ?? 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of enabled block-on-deploy policies that are eligible to apply to
|
||||
* this node: fleet-wide (node_id IS NULL) or scoped to this node. Built on
|
||||
|
||||
@@ -161,7 +161,6 @@ export default function EditorLayout() {
|
||||
activeView, setActiveView,
|
||||
settingsSection, setSettingsSection,
|
||||
securityTab, setSecurityTab,
|
||||
securityHistoryOpen, setSecurityHistoryOpen,
|
||||
filterNodeId, setFilterNodeId,
|
||||
schedulePrefill,
|
||||
mobileNavOpen, setMobileNavOpen,
|
||||
@@ -729,8 +728,6 @@ export default function EditorLayout() {
|
||||
stackName={stackName}
|
||||
gitSourceOpen={gitSourceOpen}
|
||||
setGitSourceOpen={setGitSourceOpen}
|
||||
securityHistoryOpen={securityHistoryOpen}
|
||||
setSecurityHistoryOpen={setSecurityHistoryOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import BashExecModal from '../BashExecModal';
|
||||
import LazyBoundary from '../LazyBoundary';
|
||||
import { PolicyBlockDialog } from '../stack/PolicyBlockDialog';
|
||||
import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog';
|
||||
import { DeleteStackDialog } from './DeleteStackDialog';
|
||||
@@ -14,14 +12,6 @@ import type { OverlayState } from './hooks/useOverlayState';
|
||||
import type { StackActionsHook } from './hooks/useStackActions';
|
||||
import type { PermissionAction } from '@/context/AuthContext';
|
||||
|
||||
// SecurityHistoryView is the only lazy-loaded view that lives outside
|
||||
// the ViewRouter switch -- it renders as an overlay sheet wired into the
|
||||
// settings flow, not as a top-level tab. The other tab-level lazy views
|
||||
// (HostConsole, FleetView, AuditLogView, etc.) live inside ViewRouter.
|
||||
const SecurityHistoryView = lazy(() =>
|
||||
import('../SecurityHistoryView').then(m => ({ default: m.SecurityHistoryView })),
|
||||
);
|
||||
|
||||
interface ShellOverlaysProps {
|
||||
overlayState: OverlayState;
|
||||
stackActions: StackActionsHook;
|
||||
@@ -32,8 +22,6 @@ interface ShellOverlaysProps {
|
||||
stackName: string;
|
||||
gitSourceOpen: boolean;
|
||||
setGitSourceOpen: (open: boolean) => void;
|
||||
securityHistoryOpen: boolean;
|
||||
setSecurityHistoryOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ShellOverlays({
|
||||
@@ -46,8 +34,6 @@ export function ShellOverlays({
|
||||
stackName,
|
||||
gitSourceOpen,
|
||||
setGitSourceOpen,
|
||||
securityHistoryOpen,
|
||||
setSecurityHistoryOpen,
|
||||
}: ShellOverlaysProps) {
|
||||
const {
|
||||
deleteDialogOpen, closeDeleteDialog, stackToDelete,
|
||||
@@ -171,21 +157,6 @@ export function ShellOverlays({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scan history overlay. Conditionally mounted so the lazy chunk
|
||||
only fetches when the user opens the overlay; an always-mounted
|
||||
lazy component would fetch on EditorLayout's first render and
|
||||
defeat the split. The overlay has no internal state that needs
|
||||
to persist across opens. */}
|
||||
{securityHistoryOpen ? (
|
||||
<LazyBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<SecurityHistoryView
|
||||
open
|
||||
onClose={() => setSecurityHistoryOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyBoundary>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
|
||||
// Paid-tier views and the security-history overlay are loaded on demand.
|
||||
// Their internal PaidGate / CapabilityGate wrappers render
|
||||
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
||||
// CapabilityGate wrappers render
|
||||
// the upsell or capability-missing card with blurred children rather than
|
||||
// short-circuiting, so a tier-locked or capability-missing operator
|
||||
// opening one of these tabs still triggers the chunk fetch to render the
|
||||
|
||||
@@ -58,7 +58,6 @@ describe('useViewNavigationState', () => {
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.activeView).toBe('dashboard');
|
||||
expect(result.current.settingsSection).toBe('appearance');
|
||||
expect(result.current.securityHistoryOpen).toBe(false);
|
||||
expect(result.current.filterNodeId).toBeNull();
|
||||
expect(result.current.schedulePrefill).toBeNull();
|
||||
expect(result.current.mobileNavOpen).toBe(false);
|
||||
@@ -156,18 +155,6 @@ describe('useViewNavigationState', () => {
|
||||
expect(result.current.filterNodeId).toBe(5);
|
||||
});
|
||||
|
||||
it('SENCHO_NAVIGATE_EVENT with security-history opens the sheet without changing activeView', () => {
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security-history', nodeId: 3 } }),
|
||||
);
|
||||
});
|
||||
expect(result.current.securityHistoryOpen).toBe(true);
|
||||
expect(result.current.filterNodeId).toBe(3);
|
||||
expect(result.current.activeView).toBe('dashboard');
|
||||
});
|
||||
|
||||
it('SENCHO_NAVIGATE_EVENT with no nodeId sets filterNodeId to null', () => {
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
act(() => {
|
||||
|
||||
@@ -61,7 +61,6 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
const [activeView, setActiveView] = useState<ActiveView>('dashboard');
|
||||
const [settingsSection, setSettingsSection] = useState<SectionId>('appearance');
|
||||
const [securityTab, setSecurityTab] = useState<SecurityTab>('overview');
|
||||
const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false);
|
||||
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
||||
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
@@ -89,11 +88,6 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<SenchoNavigateDetail & { view: string }>).detail;
|
||||
if (!detail?.view) return;
|
||||
if (detail.view === 'security-history') {
|
||||
setSecurityHistoryOpen(true);
|
||||
setFilterNodeId(detail.nodeId ?? null);
|
||||
return;
|
||||
}
|
||||
if (detail.view === 'security') {
|
||||
// Set the target tab before switching the view so the controlled
|
||||
// SecurityView lands on it deterministically (no mount race).
|
||||
@@ -152,7 +146,6 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
activeView, setActiveView,
|
||||
settingsSection, setSettingsSection,
|
||||
securityTab, setSecurityTab,
|
||||
securityHistoryOpen, setSecurityHistoryOpen,
|
||||
filterNodeId, setFilterNodeId,
|
||||
schedulePrefill, setSchedulePrefill,
|
||||
mobileNavOpen, setMobileNavOpen,
|
||||
|
||||
@@ -30,7 +30,7 @@ interface NodeSchedulingSummary {
|
||||
|
||||
export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate';
|
||||
export interface SenchoNavigateDetail {
|
||||
view: 'scheduled-ops' | 'auto-updates' | 'security-history' | 'security';
|
||||
view: 'scheduled-ops' | 'auto-updates' | 'security';
|
||||
nodeId?: number;
|
||||
/** Target tab when navigating to the Security view. */
|
||||
tab?: SecurityTab;
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
GitCompare,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScanComparisonSheet } from './ScanComparisonSheet';
|
||||
import { SeverityChip } from './VulnerabilityScanSheet';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
import { CapabilityGate } from './CapabilityGate';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { VulnerabilityScan } from '@/types/security';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface GroupedScans {
|
||||
image_ref: string;
|
||||
scans: VulnerabilityScan[];
|
||||
}
|
||||
|
||||
function groupByImage(scans: VulnerabilityScan[]): GroupedScans[] {
|
||||
const map = new Map<string, VulnerabilityScan[]>();
|
||||
for (const s of scans) {
|
||||
const list = map.get(s.image_ref) ?? [];
|
||||
list.push(s);
|
||||
map.set(s.image_ref, list);
|
||||
}
|
||||
const groups: GroupedScans[] = [];
|
||||
for (const [image_ref, list] of map.entries()) {
|
||||
list.sort((a, b) => b.scanned_at - a.scanned_at);
|
||||
groups.push({ image_ref, scans: list });
|
||||
}
|
||||
groups.sort((a, b) => (b.scans[0]?.scanned_at ?? 0) - (a.scans[0]?.scanned_at ?? 0));
|
||||
return groups;
|
||||
}
|
||||
|
||||
interface SecurityHistoryViewProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode, hasCapability } = useNodes();
|
||||
const scanningAvailable = hasCapability('vulnerability-scanning');
|
||||
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [capInfo, setCapInfo] = useState<{ perImageLimit: number; refs: Set<string> } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [compareIds, setCompareIds] = useState<[number, number] | null>(null);
|
||||
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const load = useCallback(async (pageToLoad: number, searchTerm: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
status: 'completed',
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(pageToLoad * PAGE_SIZE),
|
||||
});
|
||||
if (searchTerm.trim()) params.set('imageRefLike', searchTerm.trim());
|
||||
const res = await apiFetch(`/security/scans?${params.toString()}`);
|
||||
if (!res.ok) throw new Error('Failed to load scans');
|
||||
const body = await res.json();
|
||||
const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : [];
|
||||
setScans(items);
|
||||
setTotal(typeof body?.total === 'number' ? body.total : items.length);
|
||||
const limit = typeof body?.perImageLimit === 'number' ? body.perImageLimit : 0;
|
||||
const refs: string[] = Array.isArray(body?.cappedImageRefs) ? body.cappedImageRefs : [];
|
||||
setCapInfo(limit > 0 ? { perImageLimit: limit, refs: new Set(refs) } : null);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Could not load scan history');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const lastNodeIdRef = useRef<number | null>(activeNode?.id ?? null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = activeNode?.id ?? null;
|
||||
if (lastNodeIdRef.current === id) return;
|
||||
lastNodeIdRef.current = id;
|
||||
setSelected([]);
|
||||
setPage(0);
|
||||
setReloadToken((t) => t + 1);
|
||||
}, [activeNode?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !scanningAvailable) return;
|
||||
load(page, search);
|
||||
// reloadToken bumps when the active node changes even if page/search
|
||||
// happen to match the previous values, so the fetch re-runs exactly once.
|
||||
}, [open, scanningAvailable, load, page, search, reloadToken]);
|
||||
|
||||
// Skip the initial mount: the effect fires once with the original
|
||||
// searchDraft, and unconditionally resetting page to 0 after 300ms races
|
||||
// with any pagination the user may have done in that window.
|
||||
const prevSearchDraftRef = useRef(searchDraft);
|
||||
useEffect(() => {
|
||||
if (prevSearchDraftRef.current === searchDraft) return;
|
||||
prevSearchDraftRef.current = searchDraft;
|
||||
const t = setTimeout(() => {
|
||||
setSearch(searchDraft);
|
||||
setPage(0);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchDraft]);
|
||||
|
||||
const groups = useMemo(() => groupByImage(scans), [scans]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const needsPagination = total > PAGE_SIZE;
|
||||
|
||||
const toggleSelect = (scanId: number) => {
|
||||
setSelected((prev) => {
|
||||
if (prev.includes(scanId)) return prev.filter((x) => x !== scanId);
|
||||
if (prev.length >= 2) return [prev[1], scanId];
|
||||
return [...prev, scanId];
|
||||
});
|
||||
};
|
||||
|
||||
const compareSelected = () => {
|
||||
if (selected.length !== 2) return;
|
||||
const [aId, bId] = selected;
|
||||
const a = scans.find((s) => s.id === aId);
|
||||
const b = scans.find((s) => s.id === bId);
|
||||
if (!a || !b) return;
|
||||
const [older, newer] = a.scanned_at <= b.scanned_at ? [a, b] : [b, a];
|
||||
setCompareIds([older.id, newer.id]);
|
||||
};
|
||||
|
||||
const compareDisabled = selected.length !== 2;
|
||||
const meta = `${total} scan${total === 1 ? '' : 's'} · ${groups.length} image${groups.length === 1 ? '' : 's'}`;
|
||||
const footerContext = `Node ${activeNode?.name ?? '—'}`;
|
||||
|
||||
return (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => { if (!next) onClose(); }}
|
||||
crumb={['Security', 'Scan history']}
|
||||
name="Scan history"
|
||||
meta={meta}
|
||||
primaryAction={scanningAvailable ? {
|
||||
label: `Compare (${selected.length}/2)`,
|
||||
icon: GitCompare,
|
||||
onClick: compareSelected,
|
||||
disabled: compareDisabled,
|
||||
} : undefined}
|
||||
secondaryActions={scanningAvailable ? [{
|
||||
label: 'Refresh',
|
||||
icon: RefreshCw,
|
||||
onClick: () => load(safePage, search),
|
||||
disabled: loading,
|
||||
}] : []}
|
||||
footerContext={footerContext}
|
||||
size="xl"
|
||||
>
|
||||
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
|
||||
<SheetSection title="Scans" hideHeader>
|
||||
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
placeholder="Search by image..."
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{needsPagination && (
|
||||
<div className="flex items-center gap-1 ml-auto" aria-live="polite">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span
|
||||
className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center"
|
||||
aria-label={`Page ${safePage + 1} of ${totalPages}`}
|
||||
>
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && !loading ? (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
|
||||
<ShieldCheck className="w-8 h-8 text-muted-foreground" strokeWidth={1.5} />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{search
|
||||
? 'No completed scans match your search.'
|
||||
: 'No scans have completed on this node yet.'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea block className="max-h-[60vh]">
|
||||
<div className="space-y-5">
|
||||
{groups.map((group) => {
|
||||
const isCapped = capInfo?.refs.has(group.image_ref) ?? false;
|
||||
return (
|
||||
<div key={group.image_ref}>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="font-mono text-sm truncate" title={group.image_ref}>
|
||||
{group.image_ref}
|
||||
</span>
|
||||
<span className="text-xs text-stat-subtitle">
|
||||
{group.scans.length} scan{group.scans.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
{isCapped && capInfo && (
|
||||
<span className="text-xs text-stat-subtitle italic">
|
||||
Capped at {capInfo.perImageLimit} · older scans pruned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea block className="max-h-64 border border-border/40 rounded-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]" />
|
||||
<TableHead className="w-[180px]">Scanned</TableHead>
|
||||
<TableHead className="w-[120px]">Trigger</TableHead>
|
||||
<TableHead className="w-[120px]">Highest</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Total</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Fixable</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.scans.map((scan) => {
|
||||
const isSelected = selected.includes(scan.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={scan.id}
|
||||
className={cn(isSelected && 'bg-accent/30')}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(scan.id)}
|
||||
aria-label={`Select scan ${scan.id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{new Date(scan.scanned_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs capitalize">
|
||||
{scan.triggered_by}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{scan.highest_severity ? (
|
||||
<SeverityChip severity={scan.highest_severity} />
|
||||
) : (
|
||||
<span className="text-xs text-success font-mono">none</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{scan.total_vulnerabilities}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums text-success">
|
||||
{scan.fixable_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setInspectScanId(scan.id)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</SheetSection>
|
||||
</CapabilityGate>
|
||||
|
||||
<ScanComparisonSheet
|
||||
baselineScanId={compareIds?.[0] ?? null}
|
||||
currentScanId={compareIds?.[1] ?? null}
|
||||
onClose={() => setCompareIds(null)}
|
||||
/>
|
||||
|
||||
<VulnerabilityScanSheet
|
||||
scanId={inspectScanId}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
canGenerateSbom={isAdmin}
|
||||
canExportSarif={isPaid && isAdmin}
|
||||
canCompare={false}
|
||||
canManageSuppressions={isAdmin}
|
||||
/>
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
LayoutDashboard, Boxes, FileWarning, KeyRound, BookCheck, EyeOff, History as HistoryIcon, Wrench, Info,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
||||
import { PageMasthead } from '@/components/ui/PageMasthead';
|
||||
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||
@@ -13,10 +12,10 @@ import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useImageScan } from '@/hooks/useImageScan';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { SecurityOverview, ScanSummary, ScanDetailTab, FleetRole } from '@/types/security';
|
||||
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, FleetRole } from '@/types/security';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
import { SecurityHistoryView } from './SecurityHistoryView';
|
||||
import { SuppressionsPanel } from './settings/SuppressionsPanel';
|
||||
import { MisconfigAckPanel } from './settings/MisconfigAckPanel';
|
||||
import { OverviewTab } from './security/OverviewTab';
|
||||
@@ -25,6 +24,20 @@ import { FindingsTab } from './security/FindingsTab';
|
||||
import { PolicyPacksTab } from './security/PolicyPacksTab';
|
||||
import { ScanPolicyManager } from './security/ScanPolicyManager';
|
||||
import { ScannerSetupTab } from './security/ScannerSetupTab';
|
||||
import { HistoryTab } from './security/HistoryTab';
|
||||
|
||||
/** A /security/image-summaries 200 body must be a map of scan summaries. An
|
||||
* unexpected shape is treated as an error, never as a benign "no findings". An
|
||||
* empty object is valid (a node with no scans yet). */
|
||||
function isScanSummaryMap(v: unknown): v is Record<string, ScanSummary> {
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
|
||||
return Object.values(v).every(
|
||||
(s) =>
|
||||
!!s && typeof s === 'object'
|
||||
&& typeof (s as ScanSummary).image_ref === 'string'
|
||||
&& typeof (s as ScanSummary).scan_id === 'number',
|
||||
);
|
||||
}
|
||||
|
||||
interface SecurityViewProps {
|
||||
activeTab: SecurityTab;
|
||||
@@ -44,17 +57,25 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
||||
const [summaries, setSummaries] = useState<Record<string, ScanSummary>>({});
|
||||
const [summariesLoading, setSummariesLoading] = useState(true);
|
||||
const [summariesError, setSummariesError] = useState(false);
|
||||
const [trend, setTrend] = useState<SecurityRiskTrendPoint[]>([]);
|
||||
const [isReplica, setIsReplica] = useState(false);
|
||||
|
||||
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
|
||||
const [inspectInitialTab, setInspectInitialTab] = useState<ScanDetailTab | undefined>(undefined);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
const onInspect = useCallback((scanId: number, initialTab?: ScanDetailTab) => {
|
||||
setInspectInitialTab(initialTab);
|
||||
setInspectScanId(scanId);
|
||||
}, []);
|
||||
|
||||
// Scanner readiness gates the Images Actions column; an admin on a node whose
|
||||
// scanner is available can trigger scans inline.
|
||||
const canScan = isAdmin && !!overview?.scanner.available;
|
||||
const { scanningRef, scanImage } = useImageScan({
|
||||
onComplete: (scanId) => onInspect(scanId, 'vulns'),
|
||||
onSummaries: setSummaries,
|
||||
});
|
||||
|
||||
// Active-node scoped data: overview rollup + image summaries follow x-node-id.
|
||||
// A failed fetch (5xx, network, malformed body) must surface as an error, never
|
||||
// as a benign "clean / no findings" view, which for a security surface is the
|
||||
@@ -66,6 +87,14 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
||||
setSummariesLoading(true);
|
||||
setOverviewLoadError(null);
|
||||
setSummariesError(false);
|
||||
// The trend chart is non-critical: isolate its fetch entirely (transport
|
||||
// failure included, not just a non-OK/malformed body) so it can never
|
||||
// poison the overview/summaries error state. It degrades to an empty chart
|
||||
// with its own "no history" message.
|
||||
const trendPromise: Promise<SecurityRiskTrendPoint[]> = apiFetch('/security/overview/trend')
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((t) => (Array.isArray(t) ? t : []))
|
||||
.catch(() => []);
|
||||
try {
|
||||
const [overviewRes, summariesRes] = await Promise.all([
|
||||
apiFetch('/security/overview'),
|
||||
@@ -82,7 +111,15 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
||||
}
|
||||
}
|
||||
if (summariesRes.ok) {
|
||||
setSummaries(await summariesRes.json());
|
||||
const body = await summariesRes.json();
|
||||
if (isScanSummaryMap(body)) {
|
||||
setSummaries(body);
|
||||
} else {
|
||||
// A 200 with an unexpected shape must not read as "no findings".
|
||||
setSummaries({});
|
||||
setSummariesError(true);
|
||||
console.warn('[Security] image-summaries returned an unexpected shape');
|
||||
}
|
||||
} else {
|
||||
setSummaries({});
|
||||
setSummariesError(true);
|
||||
@@ -98,6 +135,8 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
||||
} finally {
|
||||
if (!cancelled) setSummariesLoading(false);
|
||||
}
|
||||
const trend = await trendPromise;
|
||||
if (!cancelled) setTrend(trend);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [activeNode?.id]);
|
||||
@@ -122,17 +161,6 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
||||
return () => { cancelled = true; };
|
||||
}, [isRemote, activeNode?.id]);
|
||||
|
||||
// A deep-link to History (e.g. the Resources "Scan history" button, which
|
||||
// mounts this view with the History tab active) auto-opens the sheet once on
|
||||
// mount. Selecting the History tab manually shows the persistent launcher
|
||||
// body instead, so the sheet does not pop on every tab click; closing it
|
||||
// always leaves the launcher.
|
||||
const deepLinkedToHistory = useRef(activeTab === 'history');
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (deepLinkedToHistory.current) setHistoryOpen(true);
|
||||
}, []);
|
||||
|
||||
const { state, tone } = deriveMasthead(overview, overviewLoadError !== null);
|
||||
const pulsing = tone === 'live' && !!overview?.scanner.available;
|
||||
|
||||
@@ -183,12 +211,27 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab overview={overview} loadError={overviewLoadError} onNavigate={onTabChange} />
|
||||
<OverviewTab
|
||||
overview={overview}
|
||||
loadError={overviewLoadError}
|
||||
summaries={summaries}
|
||||
trend={trend}
|
||||
onNavigate={onTabChange}
|
||||
onInspect={onInspect}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="images">
|
||||
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
|
||||
<ImagesTab summaries={summaries} loading={summariesLoading} error={summariesError} onInspect={onInspect} />
|
||||
<ImagesTab
|
||||
summaries={summaries}
|
||||
loading={summariesLoading}
|
||||
error={summariesError}
|
||||
onInspect={onInspect}
|
||||
canScan={canScan}
|
||||
scanningRef={scanningRef}
|
||||
onScan={scanImage}
|
||||
/>
|
||||
</CapabilityGate>
|
||||
</TabsContent>
|
||||
|
||||
@@ -206,8 +249,8 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
||||
|
||||
<TabsContent value="policies">
|
||||
<div className="space-y-8">
|
||||
<PolicyPacksTab />
|
||||
<ScanPolicyManager />
|
||||
<PolicyPacksTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -232,20 +275,7 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
||||
|
||||
<TabsContent value="history">
|
||||
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium text-sm">Scan history</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{overview
|
||||
? `${overview.scannedImages} image${overview.scannedImages === 1 ? '' : 's'} scanned · last scan ${overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never'}`
|
||||
: 'Browse completed scans and compare them.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setHistoryOpen(true)}>
|
||||
<HistoryIcon className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
Open scan history
|
||||
</Button>
|
||||
</div>
|
||||
<HistoryTab onInspect={onInspect} />
|
||||
</CapabilityGate>
|
||||
</TabsContent>
|
||||
|
||||
@@ -254,8 +284,6 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<SecurityHistoryView open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
|
||||
<VulnerabilityScanSheet
|
||||
scanId={inspectScanId}
|
||||
initialTab={inspectInitialTab}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* Coverage for SecurityHistoryView.
|
||||
*
|
||||
* Locks the scan history's selection and comparison-launch behavior: scans
|
||||
* fetched on mount, selection capped at two, oldest-first baseline ordering,
|
||||
* and selection reset on active-node change.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { VulnerabilityScan } from '@/types/security';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const licenseState = { isPaid: true };
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => licenseState,
|
||||
}));
|
||||
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
}));
|
||||
|
||||
const nodesState: {
|
||||
activeNode: { id: number; name?: string } | null;
|
||||
hasCapability: (cap: string) => boolean;
|
||||
activeNodeMeta: { version: string | null; capabilities: string[]; fetchedAt: number } | null;
|
||||
} = {
|
||||
activeNode: { id: 1 },
|
||||
hasCapability: () => true,
|
||||
activeNodeMeta: null,
|
||||
};
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => nodesState,
|
||||
}));
|
||||
|
||||
const compareProps: { baselineScanId: number | null; currentScanId: number | null }[] = [];
|
||||
vi.mock('../ScanComparisonSheet', () => ({
|
||||
ScanComparisonSheet: (props: { baselineScanId: number | null; currentScanId: number | null }) => {
|
||||
compareProps.push({ baselineScanId: props.baselineScanId, currentScanId: props.currentScanId });
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../VulnerabilityScanSheet', () => ({
|
||||
SeverityChip: ({ severity }: { severity: string }) => <span>{severity}</span>,
|
||||
VulnerabilityScanSheet: () => null,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { SecurityHistoryView } from '../SecurityHistoryView';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function scan(overrides: Partial<VulnerabilityScan> = {}): VulnerabilityScan {
|
||||
return {
|
||||
id: 1,
|
||||
node_id: 1,
|
||||
image_ref: 'alpine:3.19',
|
||||
image_digest: null,
|
||||
scanned_at: 1_700_000_000_000,
|
||||
total_vulnerabilities: 0,
|
||||
critical_count: 0,
|
||||
high_count: 0,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unknown_count: 0,
|
||||
fixable_count: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
scanners_used: 'vuln',
|
||||
highest_severity: null,
|
||||
os_info: null,
|
||||
trivy_version: null,
|
||||
scan_duration_ms: null,
|
||||
triggered_by: 'manual',
|
||||
status: 'completed',
|
||||
error: null,
|
||||
stack_context: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function listResponse(
|
||||
items: VulnerabilityScan[],
|
||||
opts: { total?: number; cappedImageRefs?: string[]; perImageLimit?: number } = {},
|
||||
): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
items,
|
||||
total: opts.total ?? items.length,
|
||||
cappedImageRefs: opts.cappedImageRefs ?? [],
|
||||
perImageLimit: opts.perImageLimit ?? 50,
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
compareProps.length = 0;
|
||||
licenseState.isPaid = true;
|
||||
nodesState.activeNode = { id: 1 };
|
||||
nodesState.hasCapability = () => true;
|
||||
nodesState.activeNodeMeta = null;
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('SecurityHistoryView', () => {
|
||||
it('fetches completed scans on mount with server-driven pagination params', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
||||
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
|
||||
const url = mockedFetch.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/^\/security\/scans\?/);
|
||||
expect(url).toContain('status=completed');
|
||||
expect(url).toContain('offset=0');
|
||||
expect(url).toMatch(/limit=\d+/);
|
||||
});
|
||||
|
||||
it('shows a lock card and does not fetch when the node lacks vulnerability-scanning', async () => {
|
||||
nodesState.hasCapability = (cap: string) => cap !== 'vulnerability-scanning';
|
||||
nodesState.activeNodeMeta = { version: '0.80.0', capabilities: [], fetchedAt: 0 };
|
||||
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
||||
|
||||
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Vulnerability scanning is not available on this node'),
|
||||
).toBeInTheDocument();
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
// The header actions are gone too, so there is no Refresh button that could
|
||||
// fire the gated fetch from behind the lock card.
|
||||
expect(screen.queryByRole('button', { name: /refresh/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /compare/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('advances offset when the user pages forward', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan()], { total: 250 }));
|
||||
const user = userEvent.setup();
|
||||
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
const nextBtn = screen.getAllByRole('button').find(
|
||||
(b) => b.querySelector('.lucide-chevron-right'),
|
||||
);
|
||||
expect(nextBtn).toBeDefined();
|
||||
await user.click(nextBtn!);
|
||||
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2));
|
||||
const secondUrl = mockedFetch.mock.calls[1][0] as string;
|
||||
expect(secondUrl).toContain('offset=100');
|
||||
});
|
||||
|
||||
it('re-fetches when activeNode.id changes', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
||||
const { rerender } = render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
nodesState.activeNode = { id: 2 };
|
||||
rerender(<SecurityHistoryView key="remount-signal" open onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('caps selection at two scans, evicting the oldest', async () => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
listResponse([
|
||||
scan({ id: 1, scanned_at: 1000 }),
|
||||
scan({ id: 2, scanned_at: 2000 }),
|
||||
scan({ id: 3, scanned_at: 3000 }),
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(3);
|
||||
|
||||
await user.click(checkboxes[0]);
|
||||
await user.click(checkboxes[1]);
|
||||
await user.click(checkboxes[2]);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeEnabled();
|
||||
expect(checkboxes[0].getAttribute('aria-checked')).toBe('false');
|
||||
expect(checkboxes[1].getAttribute('aria-checked')).toBe('true');
|
||||
expect(checkboxes[2].getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
|
||||
it('passes older scan as baseline and newer as current on compare', async () => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
listResponse([
|
||||
scan({ id: 10, scanned_at: 3000 }),
|
||||
scan({ id: 20, scanned_at: 1000 }),
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
await user.click(checkboxes[0]);
|
||||
await user.click(checkboxes[1]);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Compare \(2\/2\)/ }));
|
||||
|
||||
const last = compareProps.at(-1);
|
||||
expect(last?.baselineScanId).toBe(20);
|
||||
expect(last?.currentScanId).toBe(10);
|
||||
});
|
||||
|
||||
it('does not fetch when closed', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
||||
render(<SecurityHistoryView open={false} onClose={vi.fn()} />);
|
||||
|
||||
// Flush any microtasks; the fetch guard returns synchronously so no
|
||||
// timer delay is required.
|
||||
await Promise.resolve();
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires onClose when Escape is pressed and does not fetch again', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<SecurityHistoryView open onClose={onClose} />);
|
||||
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(mockedFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows Compare button for community tier (scan compare is Community per PR #930)', async () => {
|
||||
licenseState.isPaid = false;
|
||||
mockedFetch.mockResolvedValue(
|
||||
listResponse([
|
||||
scan({ id: 1, scanned_at: 1000 }),
|
||||
scan({ id: 2, scanned_at: 2000 }),
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
await user.click(checkboxes[0]);
|
||||
await user.click(checkboxes[1]);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders the cap hint only for images flagged in cappedImageRefs', async () => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
listResponse(
|
||||
[
|
||||
scan({ id: 1, image_ref: 'hot:latest', scanned_at: 1000 }),
|
||||
scan({ id: 2, image_ref: 'cool:latest', scanned_at: 2000 }),
|
||||
],
|
||||
{ cappedImageRefs: ['hot:latest'], perImageLimit: 50 },
|
||||
),
|
||||
);
|
||||
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||
|
||||
const cappedHint = await screen.findByText(/Capped at 50 . older scans pruned/);
|
||||
expect(cappedHint).toBeInTheDocument();
|
||||
expect(screen.queryAllByText(/Capped at 50/)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { ChevronLeft, ChevronRight, GitCompare, RefreshCw, Search, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
|
||||
import { SeverityChip } from '../VulnerabilityScanSheet';
|
||||
import { ScanComparisonSheet } from '../ScanComparisonSheet';
|
||||
import type { VulnerabilityScan, ScanDetailTab, VulnSeverity } from '@/types/security';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const SEVERITY_RANK: Record<VulnSeverity, number> = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
|
||||
|
||||
type SortKey = 'scanned_at' | 'image_ref' | 'severity' | 'total';
|
||||
|
||||
/** Sortable column header. Module-scoped so it is a stable component. */
|
||||
function SortHead({ label, k, sortKey, sortDir, onSort, align }: {
|
||||
label: string;
|
||||
k: SortKey;
|
||||
sortKey: SortKey;
|
||||
sortDir: 'asc' | 'desc';
|
||||
onSort: (k: SortKey) => void;
|
||||
align?: 'right';
|
||||
}) {
|
||||
return (
|
||||
<TableHead className={cn('text-[11px] cursor-pointer select-none', align === 'right' && 'text-right')}>
|
||||
<button type="button" onClick={() => onSort(k)} className={cn('inline-flex items-center gap-1 hover:text-stat-value', align === 'right' && 'flex-row-reverse')}>
|
||||
{label}
|
||||
{sortKey === k && (sortDir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />)}
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
interface HistoryTabProps {
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
|
||||
}
|
||||
|
||||
/** Inline scan-history table: search, sortable columns, two-scan compare, and
|
||||
* server-paginated completed scans. Replaces the former history sheet. */
|
||||
export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
|
||||
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [compareIds, setCompareIds] = useState<[number, number] | null>(null);
|
||||
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
|
||||
const load = useCallback(async (pageToLoad: number, term: string) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
status: 'completed',
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(pageToLoad * PAGE_SIZE),
|
||||
});
|
||||
if (term.trim()) params.set('imageRefLike', term.trim());
|
||||
const res = await apiFetch(`/security/scans?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data || !Array.isArray(data.items)) {
|
||||
// A 200 with an unexpected shape must surface as an error, not as an
|
||||
// empty "no completed scans yet" state.
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
setScans(data.items);
|
||||
setTotal(typeof data.total === 'number' ? data.total : data.items.length);
|
||||
} catch (err) {
|
||||
console.error('[Security] Failed to load scan history:', err);
|
||||
toast.error('Failed to load scan history');
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(safePage, search); }, [load, safePage, search, nodeId]);
|
||||
|
||||
const toggleSelect = (scanId: number) => {
|
||||
setSelected((prev) => {
|
||||
if (prev.includes(scanId)) return prev.filter((x) => x !== scanId);
|
||||
if (prev.length >= 2) return [prev[1], scanId];
|
||||
return [...prev, scanId];
|
||||
});
|
||||
};
|
||||
|
||||
const compareSelected = () => {
|
||||
if (selected.length !== 2) return;
|
||||
const [aId, bId] = selected;
|
||||
const a = scans.find((s) => s.id === aId);
|
||||
const b = scans.find((s) => s.id === bId);
|
||||
if (!a || !b) return;
|
||||
const [older, newer] = a.scanned_at <= b.scanned_at ? [a, b] : [b, a];
|
||||
setCompareIds([older.id, newer.id]);
|
||||
};
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
else { setSortKey(key); setSortDir(key === 'image_ref' ? 'asc' : 'desc'); }
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return [...scans].sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'image_ref': return a.image_ref.localeCompare(b.image_ref) * dir;
|
||||
case 'severity': return (SEVERITY_RANK[a.highest_severity ?? 'UNKNOWN'] - SEVERITY_RANK[b.highest_severity ?? 'UNKNOWN']) * dir;
|
||||
case 'total': return (a.total_vulnerabilities - b.total_vulnerabilities) * dir;
|
||||
default: return (a.scanned_at - b.scanned_at) * dir;
|
||||
}
|
||||
});
|
||||
}, [scans, sortKey, sortDir]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FleetTabHeading
|
||||
title="Scan history"
|
||||
subtitle="Completed scans across this node. Select two to compare."
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={compareSelected} disabled={selected.length !== 2}>
|
||||
<GitCompare className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
Compare ({selected.length}/2)
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => load(safePage, search)} disabled={loading}>
|
||||
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
placeholder="Search by image..."
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { setPage(0); setSearch(searchDraft); } }}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<ScrollArea className="max-h-[60vh] bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[40px]" />
|
||||
<SortHead label="Image" k="image_ref" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||
<SortHead label="Last scanned" k="scanned_at" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||
<TableHead className="text-[11px]">Trigger</TableHead>
|
||||
<SortHead label="Severity" k="severity" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||
<SortHead label="Findings" k="total" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} align="right" />
|
||||
<TableHead className="text-right text-[11px]">Fixable</TableHead>
|
||||
<TableHead className="text-right text-[11px]">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!loading && !error && sorted.map((scan) => {
|
||||
const isSelected = selected.includes(scan.id);
|
||||
return (
|
||||
<TableRow key={scan.id} className={cn('hover:bg-muted/30 transition-colors', isSelected && 'bg-accent/30')}>
|
||||
<TableCell>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelect(scan.id)} aria-label="Select scan to compare" />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs truncate max-w-[280px]">{scan.image_ref}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-stat-subtitle whitespace-nowrap">{new Date(scan.scanned_at).toLocaleString()}</TableCell>
|
||||
<TableCell className="font-mono text-xs capitalize text-stat-subtitle">{scan.triggered_by}</TableCell>
|
||||
<TableCell>
|
||||
{scan.highest_severity ? <SeverityChip severity={scan.highest_severity} /> : <span className="text-xs text-success font-mono">none</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">{scan.total_vulnerabilities}</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums text-success">{scan.fixable_count}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => onInspect(scan.id, 'vulns')}>Open</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{loading && <div className="py-12 text-center text-sm text-muted-foreground">Loading scan history...</div>}
|
||||
{!loading && error && <div className="py-12 text-center text-sm text-muted-foreground">Couldn't load scan history. Try again.</div>}
|
||||
{!loading && !error && sorted.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{search ? 'No scans match your search.' : 'No completed scans yet. Scan an image from the Images tab.'}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setPage(Math.max(0, safePage - 1))} disabled={safePage === 0}>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs text-stat-subtitle tabular-nums px-1">{safePage + 1} / {totalPages}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))} disabled={safePage >= totalPages - 1}>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScanComparisonSheet
|
||||
baselineScanId={compareIds?.[0] ?? null}
|
||||
currentScanId={compareIds?.[1] ?? null}
|
||||
onClose={() => setCompareIds(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,56 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Boxes, AlertTriangle } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Boxes, AlertTriangle, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { SeverityBadge } from '@/components/ui/SeverityBadge';
|
||||
import type { ScanSummary, ScanDetailTab } from '@/types/security';
|
||||
import { getSeverityKey, type SeverityKey } from '@/lib/severityStyles';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security';
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
type SortKey = 'image_ref' | 'scanned_at' | 'severity' | 'findings';
|
||||
|
||||
const SEVERITY_RANK: Record<SeverityKey, number> = {
|
||||
CRITICAL: 6, HIGH: 5, MEDIUM: 4, LOW: 3, UNKNOWN: 2, FINDINGS: 1, CLEAN: 0,
|
||||
};
|
||||
|
||||
/** Sortable column header. Module-scoped so it is a stable component. */
|
||||
function SortHead({ label, k, sortKey, sortDir, onSort, className }: {
|
||||
label: string;
|
||||
k: SortKey;
|
||||
sortKey: SortKey;
|
||||
sortDir: 'asc' | 'desc';
|
||||
onSort: (k: SortKey) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TableHead className={cn('text-[10px] uppercase tracking-[0.18em] cursor-pointer select-none', className)}>
|
||||
<button type="button" onClick={() => onSort(k)} className="inline-flex items-center gap-1 hover:text-stat-value">
|
||||
{label}
|
||||
{sortKey === k && (sortDir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />)}
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: Array<{ value: 'all' | SeverityKey; label: string }> = [
|
||||
{ value: 'all', label: 'All severities' },
|
||||
{ value: 'CRITICAL', label: 'Critical' },
|
||||
{ value: 'HIGH', label: 'High' },
|
||||
{ value: 'MEDIUM', label: 'Medium' },
|
||||
{ value: 'LOW', label: 'Low' },
|
||||
{ value: 'FINDINGS', label: 'Secrets / misconfigs' },
|
||||
{ value: 'CLEAN', label: 'Clean' },
|
||||
];
|
||||
|
||||
const findingsCount = (s: ScanSummary) => s.total + (s.secret_count ?? 0) + (s.misconfig_count ?? 0);
|
||||
|
||||
interface ImagesTabProps {
|
||||
summaries: Record<string, ScanSummary>;
|
||||
@@ -10,17 +58,50 @@ interface ImagesTabProps {
|
||||
/** True when the summaries fetch failed; render an error state, never a false "clean". */
|
||||
error?: boolean;
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
|
||||
/** Admin on a node with a ready scanner; gates the scan Actions column. */
|
||||
canScan: boolean;
|
||||
/** image_ref of the scan currently in flight, for the per-row spinner. */
|
||||
scanningRef: string | null;
|
||||
onScan: (imageRef: string, scanners: ScannerKind[]) => void;
|
||||
}
|
||||
|
||||
/** Latest-scan index for real images (stack/config scans live in Compose risks). */
|
||||
export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabProps) {
|
||||
const images = useMemo(
|
||||
() =>
|
||||
Object.values(summaries)
|
||||
.filter((s) => !s.image_ref.startsWith('stack:'))
|
||||
.sort((a, b) => b.scanned_at - a.scanned_at),
|
||||
[summaries],
|
||||
);
|
||||
export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan }: ImagesTabProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [severity, setSeverity] = useState('all');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return Object.values(summaries)
|
||||
.filter((s) => !s.image_ref.startsWith('stack:'))
|
||||
.filter((s) => (term ? s.image_ref.toLowerCase().includes(term) : true))
|
||||
.filter((s) => (severity === 'all' ? true : getSeverityKey(s) === severity));
|
||||
}, [summaries, search, severity]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return [...filtered].sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'image_ref': return a.image_ref.localeCompare(b.image_ref) * dir;
|
||||
case 'severity': return (SEVERITY_RANK[getSeverityKey(a)] - SEVERITY_RANK[getSeverityKey(b)]) * dir;
|
||||
case 'findings': return (findingsCount(a) - findingsCount(b)) * dir;
|
||||
default: return (a.scanned_at - b.scanned_at) * dir;
|
||||
}
|
||||
});
|
||||
}, [filtered, sortKey, sortDir]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const pageItems = sorted.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
else { setSortKey(key); setSortDir(key === 'image_ref' ? 'asc' : 'desc'); }
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -42,7 +123,8 @@ export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabPro
|
||||
);
|
||||
}
|
||||
|
||||
if (images.length === 0) {
|
||||
const noImagesAtAll = Object.values(summaries).every((s) => s.image_ref.startsWith('stack:'));
|
||||
if (noImagesAtAll) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Boxes className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
|
||||
@@ -53,38 +135,116 @@ export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabPro
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-card-border">
|
||||
<th className="text-left font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">Image</th>
|
||||
<th className="text-left font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2 max-md:hidden">Findings</th>
|
||||
<th className="text-right font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{images.map((s) => (
|
||||
<tr key={s.image_ref} className="border-b border-card-border/40 last:border-0 hover:bg-glass-highlight">
|
||||
<td className="px-4 py-2.5 font-mono text-xs truncate max-w-0 w-full">
|
||||
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, 'vulns')}>
|
||||
{s.image_ref}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-xs text-stat-subtitle max-md:hidden">
|
||||
{s.critical > 0 && <span className="text-destructive mr-2">{s.critical}C</span>}
|
||||
{s.high > 0 && <span className="text-warning mr-2">{s.high}H</span>}
|
||||
{s.secret_count > 0 && <span className="text-warning mr-2">{s.secret_count} secret</span>}
|
||||
{s.misconfig_count > 0 && <span className="text-warning mr-2">{s.misconfig_count} misconfig</span>}
|
||||
{s.fixable > 0 && <span className="text-stat-subtitle">{s.fixable} fixable</span>}
|
||||
{s.total === 0 && s.secret_count === 0 && s.misconfig_count === 0 && <span className="text-success">clean</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<SeverityBadge summary={s} onClick={() => onInspect(s.scan_id, 'vulns')} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
placeholder="Search images..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Combobox
|
||||
options={FILTER_OPTIONS}
|
||||
value={severity}
|
||||
onValueChange={(v) => { setSeverity(v || 'all'); setPage(0); }}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<ScrollArea className="max-h-[62vh] bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<SortHead label="Image" k="image_ref" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||
<SortHead label="Findings" k="findings" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} className="max-md:hidden" />
|
||||
<SortHead label="Last scan" k="scanned_at" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} className="max-md:hidden" />
|
||||
<SortHead label="Severity" k="severity" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||
{canScan && <TableHead className="text-right text-[10px] uppercase tracking-[0.18em]">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((s) => (
|
||||
<TableRow key={s.image_ref} className="hover:bg-muted/30 transition-colors">
|
||||
<TableCell className="font-mono text-xs truncate max-w-[280px]">
|
||||
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, 'vulns')}>
|
||||
{s.image_ref}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="max-md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInspect(s.scan_id, 'vulns')}
|
||||
className="font-mono tabular-nums text-xs text-stat-subtitle text-left hover:text-stat-value transition-colors"
|
||||
>
|
||||
{s.critical > 0 && <span className="text-destructive mr-2">{s.critical}C</span>}
|
||||
{s.high > 0 && <span className="text-warning mr-2">{s.high}H</span>}
|
||||
{s.secret_count > 0 && <span className="text-warning mr-2">{s.secret_count} secret</span>}
|
||||
{s.misconfig_count > 0 && <span className="text-warning mr-2">{s.misconfig_count} misconfig</span>}
|
||||
{s.fixable > 0 && <span className="text-stat-subtitle">{s.fixable} fixable</span>}
|
||||
{findingsCount(s) === 0 && <span className="text-success">clean</span>}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-stat-subtitle whitespace-nowrap max-md:hidden">
|
||||
{formatTimeAgo(s.scanned_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SeverityBadge summary={s} tooltip={false} onClick={() => onInspect(s.scan_id, 'vulns')} />
|
||||
</TableCell>
|
||||
{canScan && (
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={scanningRef === s.image_ref}
|
||||
title="Scan image"
|
||||
aria-label={`Scan ${s.image_ref}`}
|
||||
>
|
||||
{scanningRef === s.image_ref
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
: <ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onScan(s.image_ref, ['vuln'])}>
|
||||
Scan (vulnerabilities)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScan(s.image_ref, ['vuln', 'secret'])}>
|
||||
Full scan (vulnerabilities + secrets)
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{pageItems.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
No images match your search or filter.
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{sorted.length > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setPage(Math.max(0, safePage - 1))} disabled={safePage === 0} aria-label="Previous page">
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs text-stat-subtitle tabular-nums px-1">{safePage + 1} / {totalPages}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))} disabled={safePage >= totalPages - 1} aria-label="Next page">
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { ShieldOff } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { SignalRail, type SignalTile } from '@/components/ui/SignalRail';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import type { SecurityOverview } from '@/types/security';
|
||||
import type { SecurityOverview, ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import {
|
||||
SeverityDonutChart,
|
||||
RiskTrendChart,
|
||||
TopExposedImagesChart,
|
||||
FindingsByTypeChart,
|
||||
} from './SecurityCharts';
|
||||
|
||||
interface OverviewTabProps {
|
||||
overview: SecurityOverview | null;
|
||||
/** 'unsupported' = node has no overview endpoint (benign); 'failed' = a real error. */
|
||||
loadError: 'unsupported' | 'failed' | null;
|
||||
summaries: Record<string, ScanSummary>;
|
||||
trend: SecurityRiskTrendPoint[];
|
||||
onNavigate: (tab: SecurityTab) => void;
|
||||
onInspect: (scanId: number) => void;
|
||||
}
|
||||
|
||||
const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = {
|
||||
@@ -28,7 +38,16 @@ function StatusRow({ label, value, tone }: { label: string; value: string; tone?
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProps) {
|
||||
function ChartCard({ title, className, children }: { title: string; className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn('rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4', className)}>
|
||||
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-3">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, onInspect }: OverviewTabProps) {
|
||||
if (loadError === 'unsupported') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
@@ -56,12 +75,14 @@ export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProp
|
||||
if (!overview) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
<Skeleton className="h-56 w-full rounded-lg" />
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summaryList = Object.values(summaries);
|
||||
|
||||
const tiles: SignalTile[] = [
|
||||
{ kicker: 'Scanned images', value: String(overview.scannedImages) },
|
||||
{ kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value' },
|
||||
@@ -77,8 +98,26 @@ export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProp
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Signal rail of supporting counts. Wrapped so a phone scrolls the rail
|
||||
instead of crushing the fixed columns. */}
|
||||
{/* Charts lead the dashboard. */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<ChartCard title="Risk trend · 30 days · critical + high" className="lg:col-span-2">
|
||||
<RiskTrendChart trend={trend} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Severity distribution">
|
||||
<SeverityDonutChart summaries={summaryList} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="Top exposed images">
|
||||
<TopExposedImagesChart summaries={summaryList} onInspect={onInspect} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Findings by type">
|
||||
<FindingsByTypeChart summaries={summaryList} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Supporting counts + posture, secondary to the charts above. */}
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden max-md:overflow-x-auto">
|
||||
<div className="min-w-[640px]">
|
||||
<SignalRail tiles={tiles} className="border-b-0" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
@@ -30,6 +31,15 @@ function EnforcementBadge({ enforcement }: { enforcement: PolicyPackRule['enforc
|
||||
export function PolicyPacksTab() {
|
||||
const [packs, setPacks] = useState<PolicyPack[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -62,8 +72,8 @@ export function PolicyPacksTab() {
|
||||
if (!packs) {
|
||||
return (
|
||||
<div className="space-y-3" aria-busy="true">
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,38 +85,61 @@ export function PolicyPacksTab() {
|
||||
Community: they explain what good looks like. Block-on-deploy enforcement is an Admiral capability.
|
||||
</p>
|
||||
|
||||
{packs.map((pack) => (
|
||||
<div key={pack.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="border-b border-card-border px-4 py-3">
|
||||
<h3 className="font-display italic text-[18px] leading-6 text-stat-value">{pack.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{pack.tagline}</p>
|
||||
<p className="text-xs text-stat-subtitle mt-1">{pack.tierCopy}</p>
|
||||
</div>
|
||||
<ul className="divide-y divide-card-border/40">
|
||||
{pack.rules.map((rule) => (
|
||||
<li key={rule.id} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium text-sm">{rule.name}</span>
|
||||
<span className={cn('font-mono text-[10px] uppercase tracking-[0.18em]', SEVERITY_TEXT[rule.severity])}>
|
||||
{rule.severity}
|
||||
</span>
|
||||
</div>
|
||||
<EnforcementBadge enforcement={rule.enforcement} />
|
||||
<div className="space-y-3">
|
||||
{packs.map((pack) => {
|
||||
const isOpen = expanded.has(pack.id);
|
||||
return (
|
||||
<div key={pack.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(pack.id)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-glass-highlight transition-colors"
|
||||
>
|
||||
{isOpen
|
||||
? <ChevronDown className="w-4 h-4 text-stat-subtitle shrink-0" strokeWidth={1.5} />
|
||||
: <ChevronRight className="w-4 h-4 text-stat-subtitle shrink-0" strokeWidth={1.5} />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-display italic text-[18px] leading-6 text-stat-value">{pack.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{pack.tagline}</p>
|
||||
</div>
|
||||
<dl className="mt-2 grid gap-1.5 text-xs sm:grid-cols-[7rem_1fr]">
|
||||
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Checks</dt>
|
||||
<dd className="text-stat-subtitle">{rule.whatItChecks}</dd>
|
||||
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Why</dt>
|
||||
<dd className="text-stat-subtitle">{rule.why}</dd>
|
||||
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Fix</dt>
|
||||
<dd className="text-stat-subtitle">{rule.howToFix}</dd>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle shrink-0 tabular-nums">
|
||||
{pack.rules.length} rule{pack.rules.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-card-border">
|
||||
<p className="px-4 py-2 text-xs text-stat-subtitle">{pack.tierCopy}</p>
|
||||
<ul className="divide-y divide-card-border/40 border-t border-card-border/40">
|
||||
{pack.rules.map((rule) => (
|
||||
<li key={rule.id} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium text-sm">{rule.name}</span>
|
||||
<span className={cn('font-mono text-[10px] uppercase tracking-[0.18em]', SEVERITY_TEXT[rule.severity])}>
|
||||
{rule.severity}
|
||||
</span>
|
||||
</div>
|
||||
<EnforcementBadge enforcement={rule.enforcement} />
|
||||
</div>
|
||||
<dl className="mt-2 grid gap-1.5 text-xs sm:grid-cols-[7rem_1fr]">
|
||||
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Checks</dt>
|
||||
<dd className="text-stat-subtitle">{rule.whatItChecks}</dd>
|
||||
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Why</dt>
|
||||
<dd className="text-stat-subtitle">{rule.why}</dd>
|
||||
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Fix</dt>
|
||||
<dd className="text-stat-subtitle">{rule.howToFix}</dd>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,10 +252,10 @@ export function ScanPolicyManager() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Deploy enforcement policies</h3>
|
||||
{isAdmin && !isRemote && !isReplica && (
|
||||
<SettingsPrimaryButton size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4" />
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add policy
|
||||
</SettingsPrimaryButton>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* HistoryTab is the inline scan-history table that replaced the history sheet.
|
||||
* Locks: completed-scan fetch on mount with pagination params, Open -> inspect
|
||||
* on the vulns tab, two-scan compare capped at two with oldest-first baseline
|
||||
* ordering, search-by-image, and the load-failure error state.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { VulnerabilityScan } from '@/types/security';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
|
||||
const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } };
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState }));
|
||||
|
||||
const compareProps: { baselineScanId: number | null; currentScanId: number | null }[] = [];
|
||||
vi.mock('../../ScanComparisonSheet', () => ({
|
||||
ScanComparisonSheet: (props: { baselineScanId: number | null; currentScanId: number | null }) => {
|
||||
compareProps.push({ baselineScanId: props.baselineScanId, currentScanId: props.currentScanId });
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock('../../VulnerabilityScanSheet', () => ({
|
||||
SeverityChip: ({ severity }: { severity: string }) => <span>{severity}</span>,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { HistoryTab } from '../HistoryTab';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function scan(overrides: Partial<VulnerabilityScan> = {}): VulnerabilityScan {
|
||||
return {
|
||||
id: 1,
|
||||
node_id: 1,
|
||||
image_ref: 'alpine:3.19',
|
||||
image_digest: null,
|
||||
scanned_at: 1_700_000_000_000,
|
||||
total_vulnerabilities: 0,
|
||||
critical_count: 0,
|
||||
high_count: 0,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unknown_count: 0,
|
||||
fixable_count: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
scanners_used: 'vuln',
|
||||
highest_severity: null,
|
||||
os_info: null,
|
||||
trivy_version: null,
|
||||
scan_duration_ms: null,
|
||||
triggered_by: 'manual',
|
||||
status: 'completed',
|
||||
error: null,
|
||||
stack_context: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function listResponse(items: VulnerabilityScan[], total?: number): Response {
|
||||
return { ok: true, status: 200, json: async () => ({ items, total: total ?? items.length }) } as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
compareProps.length = 0;
|
||||
nodesState.activeNode = { id: 1 };
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('HistoryTab', () => {
|
||||
it('fetches completed scans on mount with pagination params', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
|
||||
const url = mockedFetch.mock.calls[0][0] as string;
|
||||
expect(url).toContain('/security/scans?');
|
||||
expect(url).toContain('status=completed');
|
||||
expect(url).toContain('limit=100');
|
||||
expect(url).toContain('offset=0');
|
||||
});
|
||||
|
||||
it('opens the scan sheet on the vulns tab from Open', async () => {
|
||||
const onInspect = vi.fn();
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({ id: 42, image_ref: 'nginx:1' })]));
|
||||
render(<HistoryTab onInspect={onInspect} />);
|
||||
await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
expect(onInspect).toHaveBeenCalledWith(42, 'vulns');
|
||||
});
|
||||
|
||||
it('compares two scans with the older as baseline and newer as current', async () => {
|
||||
const older = scan({ id: 10, image_ref: 'a:1', scanned_at: 1000 });
|
||||
const newer = scan({ id: 20, image_ref: 'b:1', scanned_at: 2000 });
|
||||
mockedFetch.mockResolvedValue(listResponse([newer, older]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument());
|
||||
const checks = screen.getAllByLabelText('Select scan to compare');
|
||||
await userEvent.click(checks[0]);
|
||||
await userEvent.click(checks[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Compare/ }));
|
||||
const last = compareProps[compareProps.length - 1];
|
||||
expect(last.baselineScanId).toBe(10);
|
||||
expect(last.currentScanId).toBe(20);
|
||||
});
|
||||
|
||||
it('caps the compare selection at two', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([
|
||||
scan({ id: 1, image_ref: 'a:1', scanned_at: 3000 }),
|
||||
scan({ id: 2, image_ref: 'b:1', scanned_at: 2000 }),
|
||||
scan({ id: 3, image_ref: 'c:1', scanned_at: 1000 }),
|
||||
]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument());
|
||||
const checks = screen.getAllByLabelText('Select scan to compare');
|
||||
await userEvent.click(checks[0]);
|
||||
await userEvent.click(checks[1]);
|
||||
await userEvent.click(checks[2]);
|
||||
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('searches by image on Enter, adding imageRefLike to the request', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
|
||||
await userEvent.type(screen.getByPlaceholderText('Search by image...'), 'redis{Enter}');
|
||||
await waitFor(() => {
|
||||
const calls = mockedFetch.mock.calls.map((c) => c[0] as string);
|
||||
expect(calls.some((u) => u.includes('imageRefLike=redis'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the error state when the load fails', async () => {
|
||||
mockedFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as unknown as Response);
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('treats a malformed 200 response (no items array) as an error, not an empty list', async () => {
|
||||
mockedFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ oops: true }) } as unknown as Response);
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/No completed scans yet/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* ImagesTab is a prop-driven index over the node's image-scan summaries. It
|
||||
* filters out stack/config scans, supports search + a severity filter, opens
|
||||
* the scan sheet from the image name and the Findings cell, and exposes inline
|
||||
* scan actions only when the caller can scan.
|
||||
*/
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ImagesTab } from '../ImagesTab';
|
||||
import type { ScanSummary } from '@/types/security';
|
||||
|
||||
function summary(o: Partial<ScanSummary> & { image_ref: string; scan_id: number }): ScanSummary {
|
||||
return {
|
||||
highest_severity: null,
|
||||
scanned_at: Date.now(),
|
||||
total: 0,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
unknown: 0,
|
||||
fixable: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
function asMap(...list: ScanSummary[]): Record<string, ScanSummary> {
|
||||
return Object.fromEntries(list.map((s) => [s.image_ref, s]));
|
||||
}
|
||||
|
||||
const base = {
|
||||
loading: false,
|
||||
error: false,
|
||||
onInspect: vi.fn(),
|
||||
canScan: false,
|
||||
scanningRef: null as string | null,
|
||||
onScan: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders real images and excludes stack/config scans', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'nginx:1', scan_id: 1, highest_severity: 'CRITICAL', total: 5, critical: 5 }),
|
||||
summary({ image_ref: 'stack:web', scan_id: 2, misconfig_count: 3 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('nginx:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('stack:web')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the scan sheet on the vulns tab from the image name', async () => {
|
||||
const onInspect = vi.fn();
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onInspect={onInspect}
|
||||
summaries={asMap(summary({ image_ref: 'nginx:1', scan_id: 7, highest_severity: 'HIGH', total: 2, high: 2 }))}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('nginx:1'));
|
||||
expect(onInspect).toHaveBeenCalledWith(7, 'vulns');
|
||||
});
|
||||
|
||||
it('opens the scan sheet on the vulns tab from the Findings cell', async () => {
|
||||
const onInspect = vi.fn();
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onInspect={onInspect}
|
||||
summaries={asMap(summary({ image_ref: 'nginx:1', scan_id: 9 }))}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('clean'));
|
||||
expect(onInspect).toHaveBeenCalledWith(9, 'vulns');
|
||||
});
|
||||
|
||||
it('narrows the list with the search box', async () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'nginx:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'redis:7', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
await userEvent.type(screen.getByPlaceholderText('Search images...'), 'redis');
|
||||
expect(screen.getByText('redis:7')).toBeInTheDocument();
|
||||
expect(screen.queryByText('nginx:1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('narrows the list with the severity filter', async () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'crit:1', scan_id: 1, highest_severity: 'CRITICAL', total: 1, critical: 1 }),
|
||||
summary({ image_ref: 'low:1', scan_id: 2, highest_severity: 'LOW', total: 1, low: 1 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('All severities'));
|
||||
await userEvent.click(screen.getByText('Critical'));
|
||||
expect(screen.getByText('crit:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('low:1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the scan action only when scanning is allowed', () => {
|
||||
const data = asMap(summary({ image_ref: 'nginx:1', scan_id: 1 }));
|
||||
const { rerender } = render(<ImagesTab {...base} canScan={false} summaries={data} />);
|
||||
expect(screen.queryByLabelText('Scan nginx:1')).not.toBeInTheDocument();
|
||||
rerender(<ImagesTab {...base} canScan={true} summaries={data} />);
|
||||
expect(screen.getByLabelText('Scan nginx:1')).toBeInTheDocument();
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
|
||||
@@ -44,18 +45,27 @@ beforeEach(() => {
|
||||
mockedFetch.mockResolvedValue(jsonResponse(200, PACKS));
|
||||
});
|
||||
|
||||
it('fetches the catalog with localOnly and renders packs and rules', async () => {
|
||||
it('fetches the catalog with localOnly and reveals rules when a pack is expanded', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PolicyPacksTab />);
|
||||
await waitFor(() => expect(screen.getByText('Homelab baseline')).toBeInTheDocument());
|
||||
expect(screen.getByText('Strict production')).toBeInTheDocument();
|
||||
expect(mockedFetch).toHaveBeenCalledWith('/security/policy-packs', { localOnly: true });
|
||||
|
||||
// Rules are collapsed behind the accordion until the pack header is clicked.
|
||||
expect(screen.queryByText('Pin image tags')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText('Homelab baseline'));
|
||||
await user.click(screen.getByText('Strict production'));
|
||||
expect(screen.getByText('Pin image tags')).toBeInTheDocument();
|
||||
expect(screen.getByText('No privileged containers')).toBeInTheDocument();
|
||||
|
||||
expect(mockedFetch).toHaveBeenCalledWith('/security/policy-packs', { localOnly: true });
|
||||
});
|
||||
|
||||
it('labels rules as warning or enforceable', async () => {
|
||||
it('labels expanded rules as warning or enforceable', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PolicyPacksTab />);
|
||||
await waitFor(() => expect(screen.getByText('Warning')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Homelab baseline')).toBeInTheDocument());
|
||||
await user.click(screen.getByText('Homelab baseline'));
|
||||
await user.click(screen.getByText('Strict production'));
|
||||
expect(screen.getByText('Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enforceable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
import { ChevronLeft, ChevronRight, Plus, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
|
||||
import type { MisconfigAcknowledgement } from '@/types/security';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
@@ -145,54 +146,48 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm">Misconfig Acknowledgements</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0 font-mono tabular-nums">
|
||||
{rows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{needsPagination && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && !isReplica && (
|
||||
<div className="space-y-4">
|
||||
<FleetTabHeading
|
||||
title="Misconfig acknowledgements"
|
||||
subtitle="Accept known-benign misconfigurations so they stop triggering alerts. Acknowledgements apply at read time across the fleet and never modify stored scan data."
|
||||
action={
|
||||
isAdmin && !isReplica ? (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add Acknowledgement
|
||||
Add acknowledgement
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Accept known-benign misconfigurations so they stop triggering alerts. Acknowledgements apply at read time
|
||||
across every instance in the fleet and never modify stored scan data.
|
||||
</p>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
|
||||
{needsPagination && !loading && rows.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-2">
|
||||
@@ -248,6 +243,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
|
||||
<ModalHeader
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
import { ChevronLeft, ChevronRight, Plus, ShieldOff, Trash2 } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
|
||||
import type { CveSuppression } from '@/types/security';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
@@ -148,54 +149,48 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ShieldOff className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm">CVE Suppressions</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0 font-mono tabular-nums">
|
||||
{rows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{needsPagination && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && !isReplica && (
|
||||
<div className="space-y-4">
|
||||
<FleetTabHeading
|
||||
title="CVE suppressions"
|
||||
subtitle="Accept known-benign CVEs so they stop triggering alerts. Suppressions apply at read time across the fleet and never modify stored scan data."
|
||||
action={
|
||||
isAdmin && !isReplica ? (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add Suppression
|
||||
Add suppression
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Accept known-benign CVEs so they stop triggering alerts. Suppressions apply at read time across every
|
||||
instance in the fleet and never modify stored scan data.
|
||||
</p>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
|
||||
{needsPagination && !loading && rows.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-2">
|
||||
@@ -256,6 +251,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
|
||||
<ModalHeader
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* getSeverityKey is the single classifier shared by the severity badge, the
|
||||
* Images severity sort, and the Images severity filter. Lock its mapping so the
|
||||
* three consumers can never disagree: a CVE severity wins, a secret/misconfig-only
|
||||
* scan is FINDINGS (not a false "Clean"), and an all-zero scan is CLEAN.
|
||||
*/
|
||||
import { it, expect } from 'vitest';
|
||||
import { getSeverityKey } from '../severityStyles';
|
||||
import type { ScanSummary } from '@/types/security';
|
||||
|
||||
function summary(o: Partial<ScanSummary>): ScanSummary {
|
||||
return {
|
||||
image_ref: 'x:1',
|
||||
highest_severity: null,
|
||||
scanned_at: 1,
|
||||
scan_id: 1,
|
||||
total: 0,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
unknown: 0,
|
||||
fixable: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
it('returns the highest CVE severity when present', () => {
|
||||
expect(getSeverityKey(summary({ highest_severity: 'CRITICAL' }))).toBe('CRITICAL');
|
||||
expect(getSeverityKey(summary({ highest_severity: 'LOW' }))).toBe('LOW');
|
||||
});
|
||||
|
||||
it('returns FINDINGS for a secret- or misconfig-only scan with no CVE severity', () => {
|
||||
expect(getSeverityKey(summary({ highest_severity: null, secret_count: 1 }))).toBe('FINDINGS');
|
||||
expect(getSeverityKey(summary({ highest_severity: null, misconfig_count: 2 }))).toBe('FINDINGS');
|
||||
});
|
||||
|
||||
it('returns CLEAN when there are no findings of any kind', () => {
|
||||
expect(getSeverityKey(summary({ highest_severity: null }))).toBe('CLEAN');
|
||||
});
|
||||
@@ -1,4 +1,17 @@
|
||||
import type { VulnSeverity } from '@/types/security';
|
||||
import type { ScanSummary, VulnSeverity } from '@/types/security';
|
||||
|
||||
export type SeverityKey = VulnSeverity | 'CLEAN' | 'FINDINGS';
|
||||
|
||||
/**
|
||||
* The display "key" for a scan summary: its highest vulnerability severity, or
|
||||
* `FINDINGS` when the scan has only secrets/misconfigurations (stored
|
||||
* highest_severity is derived from CVEs alone), or `CLEAN` when nothing was
|
||||
* found. Shared so the badge, sorting, and filtering all classify identically.
|
||||
*/
|
||||
export function getSeverityKey(summary: ScanSummary): SeverityKey {
|
||||
const hasNonVulnFindings = (summary.secret_count ?? 0) > 0 || (summary.misconfig_count ?? 0) > 0;
|
||||
return summary.highest_severity ?? (hasNonVulnFindings ? 'FINDINGS' : 'CLEAN');
|
||||
}
|
||||
|
||||
export const SEVERITY_ROW_TINT: Record<VulnSeverity, string> = {
|
||||
CRITICAL: 'bg-destructive/10 border-l-[3px] border-destructive/70',
|
||||
@@ -14,7 +27,7 @@ export const SEVERITY_ROW_TINT: Record<VulnSeverity, string> = {
|
||||
* state for a scan that has secrets or misconfigurations but zero CVEs (the
|
||||
* stored highest_severity is derived from vulnerabilities only).
|
||||
*/
|
||||
export const SEVERITY_BADGE_CLASSES: Record<VulnSeverity | 'CLEAN' | 'FINDINGS', string> = {
|
||||
export const SEVERITY_BADGE_CLASSES: Record<SeverityKey, string> = {
|
||||
CRITICAL: 'border-destructive/25 bg-destructive/8 text-destructive',
|
||||
HIGH: 'border-warning/25 bg-warning/8 text-warning',
|
||||
MEDIUM: 'border-warning/25 bg-warning/8 text-warning',
|
||||
@@ -25,7 +38,7 @@ export const SEVERITY_BADGE_CLASSES: Record<VulnSeverity | 'CLEAN' | 'FINDINGS',
|
||||
};
|
||||
|
||||
/** Leading state-dot color for a severity pill. */
|
||||
export const SEVERITY_DOT_CLASSES: Record<VulnSeverity | 'CLEAN' | 'FINDINGS', string> = {
|
||||
export const SEVERITY_DOT_CLASSES: Record<SeverityKey, string> = {
|
||||
CRITICAL: 'bg-destructive',
|
||||
HIGH: 'bg-warning',
|
||||
MEDIUM: 'bg-warning',
|
||||
|
||||
@@ -205,6 +205,16 @@ export interface SecurityOverview {
|
||||
/** Which detail tab the scan sheet opens on. Matches VulnerabilityScanSheet's tabs. */
|
||||
export type ScanDetailTab = 'vulns' | 'secrets' | 'misconfigs';
|
||||
|
||||
/** Scanner kinds a scan request can run. Mirrors the backend's accepted set. */
|
||||
export type ScannerKind = 'vuln' | 'secret';
|
||||
|
||||
/** One day's Critical/High totals for the Security overview risk-trend chart. */
|
||||
export interface SecurityRiskTrendPoint {
|
||||
date: string;
|
||||
critical: number;
|
||||
high: number;
|
||||
}
|
||||
|
||||
export type PolicyRuleEnforcement = 'warning' | 'enforceable';
|
||||
|
||||
export interface PolicyPackRule {
|
||||
|
||||
Reference in New Issue
Block a user