feat(frontend): SystemSheet §9.11 — security and scheduled sheets (PR 2/3) (#961)

* feat(frontend): add SystemSheet primitive and migrate mesh sheets to §9.11 chrome

DESIGN.md §9.11 codifies one canonical right-side detail-sheet shell (cyan
rail, mono crumb, italic serif name, mono meta, ESC chip + close glyph,
fixed three-slot toolbar, cyan-underline tabs, ScrollArea body, footer
freshness band). Today the 16 sheet consumers each render their own
header chrome with stock shadcn SheetHeader/SheetTitle.

Introduce <SystemSheet> + <SheetSection> in
frontend/src/components/ui/system-sheet.tsx, composing the existing
<Sheet>/<SheetContent> primitive. Add a backward-compatible showClose
prop to SheetContent so SystemSheet can render its own ESC chip + close
glyph instead of the stock cyan square close.

Migrate the four mesh sheets as the first batch:
* MeshActivitySheet: crumb Fleet › Mesh › Activity, footer freshness from
  most-recent event timestamp.
* MeshOptInSheet: crumb Fleet › Mesh › {nodeName}, meta of opted-in
  count, drops the redundant bottom Close button (ESC chip dismisses).
* MeshDiagnosticsSheet: removes the icon-prefixed title (forbidden by
  §9.11), lifts Refresh/Restart buttons from the body into the toolbar
  band, three SheetSection blocks for sidecar status, streams, cache.
* MeshRouteDetailSheet: adds Overview/Events/Raw tabs, lifts Test probe
  into the toolbar primary slot, footer surfaces last probe latency.

* feat(frontend): migrate security and scheduled sheets to §9.11 chrome

PR 2 of the System Sheet (§9.11) rollout, stacked on the SystemSheet
primitive PR. Migrates six more sheet consumers and merges the Stack
alert + auto-heal sheets into a single tabbed sheet per audit §17.

Sheets migrated:
* NodeUpdatesSheet: crumb Fleet > Updates, Recheck primary, Update-all
  secondary when applicable. Stat tiles and node rows lose their
  card-in-sheet wrapping (forbidden by §9.11) for flat dividers.
* StackAlertSheet (now the merged stack monitor): tabs Alerts /
  Auto-heal, crumb Stack > {name} > Monitor. New initialTab prop lets
  callers open directly to either tab. Auto-heal tab is hidden entirely
  for Community-tier users (matches the existing tier-gating on the
  context menu trigger and keyboard shortcut).
* StackAutoHealSheet.tsx: deleted. Its body became the Auto-heal tab
  inside the merged sheet.
* VulnerabilityScanSheet: removed the icon-prefixed title (forbidden by
  §9.11). Re-scan, Compare, CSV, SARIF lifted from body cards into the
  toolbar band. Tabs Vulnerabilities | Secrets | Misconfigs (counts on
  the tab labels). SBOM dropdown stays in the body summary section
  pending a primitive enhancement for dropdown-attached toolbar actions.
* ScanComparisonSheet: crumb Security > Scans > Compare, name Diff,
  meta with the +added/-removed delta.
* SecurityHistoryView: the inner sheet only. Crumb Security > Scan
  history. Compare (paid + 2 selected) and Refresh in toolbar.
* ScheduledOperationsView run-history sheet (lines ~847-935 only):
  crumb Schedules > {taskName} > Runs, Download CSV in toolbar, footer
  surfaces next-run timestamp.

Hook refactor:
* useOverlayState replaces three separate state vars (alertSheetOpen,
  alertSheetStack, autoHealStackName) with one stackMonitor object
  carrying { stackName, tab }. New helpers openAlertSheet(stackName),
  openAutoHeal(stackName), closeStackMonitor(). Tests rewritten and
  pass (11/11).
* useSidebarContextMenu and ShellOverlays updated for the new API. The
  three other call sites (useStackMenuItems, useStackKeyboardShortcuts)
  already use openAlertSheet/openAutoHeal and need no change.
This commit is contained in:
Anso
2026-05-06 23:19:16 -04:00
committed by GitHub
parent 4d9617a5c6
commit 3ec0a45ff0
11 changed files with 1307 additions and 1507 deletions
+170 -173
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import {
@@ -14,7 +14,6 @@ import {
ArrowRight,
ChevronLeft,
ChevronRight,
GitCompare,
Loader2,
MinusCircle,
PlusCircle,
@@ -145,93 +144,93 @@ export function ScanComparisonSheet({
const pageItems = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const needsPagination = rows.length > PAGE_SIZE;
const meta = data
? `#${data.scanA.id} → #${data.scanB.id} · +${data.added.length} ${data.removed.length}`
: (loading ? 'Loading…' : '');
const footerContext = data
? `${data.scanA.image_ref}${data.scanB.image_ref}`
: undefined;
return (
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
<SheetContent className="sm:max-w-4xl flex flex-col p-0">
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Scan comparison
</div>
<SheetTitle className="flex items-center gap-2 font-display italic text-2xl">
<GitCompare className="w-5 h-5 text-muted-foreground not-italic" strokeWidth={1.5} />
Diff
</SheetTitle>
<SheetDescription className="sr-only">
Side-by-side comparison of two vulnerability scans showing added, removed, and unchanged findings.
</SheetDescription>
</SheetHeader>
<SystemSheet
open={open}
onOpenChange={(o) => !o && onClose()}
crumb={['Security', 'Scans', 'Compare']}
name="Diff"
meta={meta}
footerContext={footerContext}
size="xl"
>
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
</div>
)}
{loading && (
<div className="flex items-center justify-center flex-1">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
</div>
)}
{data && !loading && (
<div className="flex flex-col flex-1 min-h-0">
{/* Scan identification */}
<div className="px-6 py-4 border-b space-y-3">
<div className="flex items-center gap-3 text-xs font-mono tabular-nums">
<div className="flex-1 min-w-0">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Baseline</div>
<div className="text-stat-value truncate">{data.scanA.image_ref}</div>
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<div className="flex-1 min-w-0">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Current</div>
<div className="text-stat-value truncate">{data.scanB.image_ref}</div>
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
</div>
{data && !loading && (
<>
<SheetSection title="Scans">
<div className="flex items-center gap-3 text-xs font-mono tabular-nums mb-3">
<div className="flex-1 min-w-0">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Baseline</div>
<div className="text-stat-value truncate">{data.scanA.image_ref}</div>
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<div className="flex-1 min-w-0">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Current</div>
<div className="text-stat-value truncate">{data.scanB.image_ref}</div>
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
</div>
{crossImage && (
<div className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
<span>
You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift.
</span>
</div>
)}
{data.truncated && (
<div
role="alert"
className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
>
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
<span>
Showing the first {data.row_limit ?? 1000} findings per scan. One or both scans exceed this limit, so the comparison may be incomplete.
</span>
</div>
)}
{/* Delta ribbon */}
{addedCounts && removedCounts && (
<div className="flex flex-wrap gap-2">
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => {
const delta = formatDelta(sev, addedCounts[sev], removedCounts[sev]);
return (
<span
key={sev}
aria-label={`${sev} delta ${delta.text}`}
data-tone={delta.tone}
className={cn(
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums shadow-card-bevel',
DELTA_TONE_CLASS[delta.tone],
)}
>
<span className="uppercase tracking-[0.18em] text-[10px]">{sev}</span>
<span>{delta.text}</span>
</span>
);
})}
</div>
)}
</div>
{/* Filter pills */}
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
{crossImage && (
<div className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 mb-3 text-xs text-warning">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
<span>
You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift.
</span>
</div>
)}
{data.truncated && (
<div
role="alert"
className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 mb-3 text-xs text-warning"
>
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
<span>
Showing the first {data.row_limit ?? 1000} findings per scan. One or both scans exceed this limit, so the comparison may be incomplete.
</span>
</div>
)}
{addedCounts && removedCounts && (
<div className="flex flex-wrap gap-2">
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => {
const delta = formatDelta(sev, addedCounts[sev], removedCounts[sev]);
return (
<span
key={sev}
aria-label={`${sev} delta ${delta.text}`}
data-tone={delta.tone}
className={cn(
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums shadow-card-bevel',
DELTA_TONE_CLASS[delta.tone],
)}
>
<span className="uppercase tracking-[0.18em] text-[10px]">{sev}</span>
<span>{delta.text}</span>
</span>
);
})}
</div>
)}
</SheetSection>
<SheetSection title={`Findings · ${filter}`}>
<div className="flex items-center gap-1 flex-wrap mb-3">
<Button
variant={filter === 'added' ? 'default' : 'ghost'}
size="sm"
@@ -296,98 +295,96 @@ export function ScanComparisonSheet({
)}
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="px-6 py-3">
{pageItems.length === 0 ? (
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
<ShieldCheck className="w-8 h-8 text-success" strokeWidth={1.5} />
<div className="text-sm text-muted-foreground">
{filter === 'added' && 'No new findings. Nothing regressed between these scans.'}
{filter === 'removed' && 'No findings were resolved between these scans.'}
{filter === 'unchanged' && 'No findings are shared between the two scans.'}
</div>
<ScrollArea block className="max-h-[60vh]">
{pageItems.length === 0 ? (
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
<ShieldCheck className="w-8 h-8 text-success" strokeWidth={1.5} />
<div className="text-sm text-muted-foreground">
{filter === 'added' && 'No new findings. Nothing regressed between these scans.'}
{filter === 'removed' && 'No findings were resolved between these scans.'}
{filter === 'unchanged' && 'No findings are shared between the two scans.'}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((v, idx) => {
const href = cveUrl(v.vulnerability_id, v.primary_url);
const rowClass = cn(
SEVERITY_ROW_TINT[v.severity],
filter === 'unchanged' && 'opacity-75',
v.suppressed && 'opacity-60',
);
return (
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
<TableCell className="font-mono text-xs tabular-nums">
<span className="inline-flex items-center gap-1.5">
{v.suppressed && (
<ShieldOff
className="w-3 h-3 text-muted-foreground"
strokeWidth={1.5}
aria-label="Suppressed"
/>
)}
{href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="hover:underline"
>
{v.vulnerability_id}
</a>
) : (
v.vulnerability_id
)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((v, idx) => {
const href = cveUrl(v.vulnerability_id, v.primary_url);
const rowClass = cn(
SEVERITY_ROW_TINT[v.severity],
filter === 'unchanged' && 'opacity-75',
v.suppressed && 'opacity-60',
);
return (
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
<TableCell className="font-mono text-xs tabular-nums">
<span className="inline-flex items-center gap-1.5">
{v.suppressed && (
<ShieldOff
className="w-3 h-3 text-muted-foreground"
strokeWidth={1.5}
aria-label="Suppressed"
/>
)}
{href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="hover:underline"
>
{v.vulnerability_id}
</a>
) : (
v.vulnerability_id
)}
</span>
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={v.pkg_name}>
{v.pkg_name}
</TableCell>
<TableCell>
<SeverityChip severity={v.severity} />
</TableCell>
<TableCell className="font-mono text-xs">
{filter === 'added' && (
<span className="inline-flex items-center gap-1 text-destructive">
<PlusCircle className="w-3 h-3" strokeWidth={1.5} />
Added
</span>
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={v.pkg_name}>
{v.pkg_name}
</TableCell>
<TableCell>
<SeverityChip severity={v.severity} />
</TableCell>
<TableCell className="font-mono text-xs">
{filter === 'added' && (
<span className="inline-flex items-center gap-1 text-destructive">
<PlusCircle className="w-3 h-3" strokeWidth={1.5} />
Added
</span>
)}
{filter === 'removed' && (
<span className="inline-flex items-center gap-1 text-success">
<MinusCircle className="w-3 h-3" strokeWidth={1.5} />
Removed
</span>
)}
{filter === 'unchanged' && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Equal className="w-3 h-3" strokeWidth={1.5} />
{crossImage ? 'Shared' : 'Unchanged'}
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
)}
{filter === 'removed' && (
<span className="inline-flex items-center gap-1 text-success">
<MinusCircle className="w-3 h-3" strokeWidth={1.5} />
Removed
</span>
)}
{filter === 'unchanged' && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Equal className="w-3 h-3" strokeWidth={1.5} />
{crossImage ? 'Shared' : 'Unchanged'}
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</ScrollArea>
</div>
)}
</SheetContent>
</Sheet>
</SheetSection>
</>
)}
</SystemSheet>
);
}