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
+162 -189
View File
@@ -3,13 +3,7 @@ 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 {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import {
Table,
TableBody,
@@ -153,198 +147,177 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
};
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 (
<Sheet open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
<SheetContent className="sm:max-w-4xl flex flex-col p-0 gap-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">
Security · Node {activeNode?.name ?? '-'}
<SystemSheet
open={open}
onOpenChange={(next) => { if (!next) onClose(); }}
crumb={['Security', 'Scan history']}
name="Scan history"
meta={meta}
primaryAction={isPaid ? {
label: `Compare (${selected.length}/2)`,
icon: GitCompare,
onClick: compareSelected,
disabled: compareDisabled,
} : undefined}
secondaryActions={[{
label: 'Refresh',
icon: RefreshCw,
onClick: () => load(safePage, search),
disabled: loading,
}]}
footerContext={footerContext}
size="xl"
>
<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>
<div className="flex items-center justify-between gap-3">
<SheetTitle className="font-display italic text-2xl">Scan history</SheetTitle>
<div className="flex items-center gap-2">
{isPaid && (
<Button
variant="outline"
size="sm"
className="border-border"
onClick={compareSelected}
disabled={compareDisabled}
title={compareDisabled
? 'Select exactly two completed scans to compare'
: 'Compare the two selected scans'}
>
<GitCompare className="w-4 h-4 mr-2" strokeWidth={1.5} />
Compare ({selected.length}/2)
</Button>
)}
{needsPagination && (
<div className="flex items-center gap-1 ml-auto" aria-live="polite">
<Button
variant="outline"
size="sm"
className="border-border"
onClick={() => load(safePage, search)}
disabled={loading}
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
aria-label="Previous page"
>
<RefreshCw
className={cn('w-4 h-4 mr-2', loading && 'animate-spin')}
strokeWidth={1.5}
/>
Refresh
<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>
<SheetDescription className="text-sm text-muted-foreground">
Completed vulnerability scans on this node, grouped by image. Select two to compare.
</SheetDescription>
</SheetHeader>
<div className="flex-1 flex flex-col px-6 py-4 overflow-hidden">
<div className="flex items-center gap-3 mb-4 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 className="flex-1 min-h-0">
<div className="space-y-5 pr-2">
{groups.map((group) => (
<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>
</div>
<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>
</div>
))}
</div>
</ScrollArea>
)}
</div>
<ScanComparisonSheet
baselineScanId={compareIds?.[0] ?? null}
currentScanId={compareIds?.[1] ?? null}
onClose={() => setCompareIds(null)}
/>
{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) => (
<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>
</div>
<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>
</div>
))}
</div>
</ScrollArea>
)}
</SheetSection>
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => setInspectScanId(null)}
canGenerateSbom={isPaid}
canCompare={false}
canManageSuppressions={isPaid && isAdmin}
/>
</SheetContent>
</Sheet>
<ScanComparisonSheet
baselineScanId={compareIds?.[0] ?? null}
currentScanId={compareIds?.[1] ?? null}
onClose={() => setCompareIds(null)}
/>
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => setInspectScanId(null)}
canGenerateSbom={isPaid}
canCompare={false}
canManageSuppressions={isPaid && isAdmin}
/>
</SystemSheet>
);
}