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