Files
sencho/frontend/src/components/security/OverviewTab.tsx
T
Anso ef5a3f00a7 feat: add an on-demand node-wide security scan with live progress (#1367)
Add a "Scan this node" action on the Security overview that scans, in one pass,
any combination of three types: image vulnerabilities, image secrets, and
compose misconfigurations. Progress streams live into the deploy-feedback modal.

- TrivyService.scanNode runs the selected scanners across the node's images and,
  for misconfig, every stack's compose file, behind a per-node lock and tolerant
  of per-item failures. The existing scanAllNodeImages becomes a thin vuln-only
  wrapper over the shared image loop, so scheduled scans are unchanged.
- POST /api/security/scan-node (admin, scanner-gated) streams sanitized progress
  to the deploy terminal and returns a combined summary. Secret scans stream
  counts only, never matched values.
- Frontend adds a "scan" action verb and a ScanNodeLauncher wired into the
  overview; the scan stays bound to the node it started on even if the active
  node changes mid-run.
2026-06-12 19:07:45 -04:00

186 lines
7.8 KiB
TypeScript

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, ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
import type { SecurityTab } from '@/lib/events';
import {
SeverityDonutChart,
RiskTrendChart,
TopExposedImagesChart,
FindingsByTypeChart,
} from './SecurityCharts';
import { ScanNodeLauncher } from './ScanNodeLauncher';
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;
/** Admin on a node with a ready scanner; enables the node-scan launcher. */
canScan: boolean;
/** Refresh the overview after a node-wide scan completes. */
onScanComplete: () => void;
}
const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = {
value: 'text-stat-value',
warn: 'text-warning',
subtitle: 'text-stat-subtitle',
};
function StatusRow({ label, value, tone }: { label: string; value: string; tone?: 'value' | 'warn' | 'subtitle' }) {
const toneClass = STATUS_ROW_TONE[tone ?? 'value'];
return (
<div className="flex items-center justify-between gap-4 py-[var(--density-cell-y)]">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">{label}</span>
<span className={`font-mono tabular-nums text-sm ${toneClass}`}>{value}</span>
</div>
);
}
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, canScan, onScanComplete }: OverviewTabProps) {
if (loadError === 'unsupported') {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<ShieldOff className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium mb-1">Overview unavailable on this node</h3>
<p className="text-sm text-muted-foreground">
This node does not report a security overview. Browse images, history, and scanner setup directly.
</p>
</div>
);
}
if (loadError === 'failed') {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<ShieldOff className="w-12 h-12 text-warning/60 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium mb-1">Couldn't load the overview</h3>
<p className="text-sm text-muted-foreground">
The security overview failed to load for this node. Switch nodes and back, or try again shortly.
</p>
</div>
);
}
if (!overview) {
return (
<div className="space-y-4" aria-busy="true">
<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' },
{ kicker: 'Secrets', value: String(overview.secrets), tone: overview.secrets > 0 ? 'error' : 'value' },
{ kicker: 'Misconfigs', value: String(overview.misconfigs), tone: overview.misconfigs > 0 ? 'warn' : 'value' },
{ kicker: 'Stale', value: String(overview.staleScans), tone: overview.staleScans > 0 ? 'warn' : 'value' },
{ kicker: 'Failed', value: String(overview.failedScans), tone: overview.failedScans > 0 ? 'error' : 'value' },
];
const scannerValue = overview.scanner.available
? `${overview.scanner.source}${overview.scanner.version ? ` · v${overview.scanner.version}` : ''}`
: 'not installed';
return (
<div className="space-y-6">
{canScan && (
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-stat-subtitle">
{overview.scannedImages === 0
? 'No images scanned on this node yet.'
: `${overview.scannedImages} image${overview.scannedImages === 1 ? '' : 's'} scanned.`}
</p>
<ScanNodeLauncher canScan={canScan} onComplete={onScanComplete} />
</div>
)}
{/* 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" />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-2">Scanner</h3>
<StatusRow label="Status" value={scannerValue} tone={overview.scanner.available ? 'value' : 'warn'} />
{overview.scanner.source === 'managed' && (
<StatusRow label="Auto-update" value={overview.scanner.autoUpdate ? 'on' : 'off'} tone="subtitle" />
)}
<StatusRow
label="Last scan"
value={overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never'}
tone="subtitle"
/>
{!overview.scanner.available && (
<button
type="button"
onClick={() => onNavigate('scanner')}
className="mt-2 text-xs text-brand hover:underline"
>
Set up the scanner
</button>
)}
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-2">Deploy enforcement</h3>
<StatusRow
label="Block policies"
value={String(overview.deployEnforcement.eligibleBlockPolicies)}
tone={overview.deployEnforcement.eligibleBlockPolicies > 0 ? 'value' : 'subtitle'}
/>
<StatusRow
label="Honor suppressions"
value={overview.deployEnforcement.honorSuppressionsOnDeploy ? 'on' : 'off'}
tone="subtitle"
/>
<p className="mt-2 text-xs text-muted-foreground">
Manage enforcement policies on the Policies tab. This is a read-only posture for the active node.
</p>
</div>
</div>
</div>
);
}