mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user