mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +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:
@@ -3,8 +3,7 @@ import {
|
||||
Search, Loader2, Check, CircleCheck, CircleAlert, AlertTriangle,
|
||||
Download, RefreshCw, Monitor, Globe,
|
||||
} from 'lucide-react';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -64,174 +63,160 @@ export function NodeUpdatesSheet({
|
||||
const localEntry = updateStatuses.find(s => s.type === 'local') ?? updateStatuses[0];
|
||||
const gatewayLabel = formatVersion(localEntry?.latestVersion);
|
||||
|
||||
const meta = updateStatuses.length === 0
|
||||
? 'No nodes'
|
||||
: `${updateStatuses.length} nodes · ${available} update${available === 1 ? '' : 's'} available`;
|
||||
|
||||
const footerContext = updateStatuses.length === 0
|
||||
? undefined
|
||||
: (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`);
|
||||
|
||||
const secondaryActions = canBulkUpdate && updatableRemoteCount > 0
|
||||
? [{
|
||||
label: `Update all (${updatableRemoteCount})`,
|
||||
icon: Download,
|
||||
onClick: () => { void triggerUpdateAll(); },
|
||||
}]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
<SheetContent side="right" className="w-[700px] sm:max-w-[700px] flex flex-col p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 shrink-0 border-b">
|
||||
<SheetTitle>Node Updates</SheetTitle>
|
||||
<SheetDescription className="sr-only">Check and apply updates across your fleet nodes.</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{checkingUpdates ? (
|
||||
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Checking for updates...
|
||||
</div>
|
||||
) : updateStatuses.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
|
||||
No nodes found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Summary stats */}
|
||||
<div className="px-6 pt-4 pb-3 shrink-0">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{upToDate}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<CircleCheck className="w-3 h-3 text-success" strokeWidth={1.5} /> Up to date
|
||||
</div>
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
crumb={['Fleet', 'Updates']}
|
||||
name="Node updates"
|
||||
meta={meta}
|
||||
primaryAction={{
|
||||
label: 'Recheck',
|
||||
icon: recheckingUpdates ? Loader2 : RefreshCw,
|
||||
onClick: () => { void handleRecheck(); },
|
||||
disabled: recheckingUpdates || checkingUpdates,
|
||||
}}
|
||||
secondaryActions={secondaryActions}
|
||||
footerContext={footerContext}
|
||||
size="lg"
|
||||
>
|
||||
{checkingUpdates ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Checking for updates...
|
||||
</div>
|
||||
) : updateStatuses.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
No nodes found.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SheetSection title="Summary">
|
||||
<div className="grid grid-cols-4 gap-x-4 divide-x divide-card-border/40 text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{upToDate}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<CircleCheck className="w-3 h-3 text-success" strokeWidth={1.5} /> Up to date
|
||||
</div>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{available}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<CircleAlert className="w-3 h-3 text-warning" strokeWidth={1.5} /> Available
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{available}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<CircleAlert className="w-3 h-3 text-warning" strokeWidth={1.5} /> Available
|
||||
</div>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{updating}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<Loader2 className="w-3 h-3 text-brand" strokeWidth={1.5} /> Updating
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{updating}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<Loader2 className="w-3 h-3 text-brand" strokeWidth={1.5} /> Updating
|
||||
</div>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{failed}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-destructive/70" strokeWidth={1.5} /> Failed
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{failed}</div>
|
||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-destructive/70" strokeWidth={1.5} /> Failed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{/* Search + gateway version */}
|
||||
<div className="flex items-center gap-2 px-6 pb-3 shrink-0">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter nodes..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{gatewayLabel && (
|
||||
<div className="text-[11px] text-muted-foreground shrink-0">
|
||||
Latest: <span className="font-mono tabular-nums text-foreground">{gatewayLabel}</span>
|
||||
<SheetSection title={`Nodes · ${updateStatuses.length}`}>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter nodes..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 px-3 pb-1 text-[10px] leading-3 font-mono text-stat-subtitle uppercase tracking-[0.18em]">
|
||||
<span>Node</span>
|
||||
<span>Type</span>
|
||||
<span>Current</span>
|
||||
<span>Latest</span>
|
||||
<span className="text-right">Status</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-card-border/40">
|
||||
{filtered.map(s => (
|
||||
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 items-center px-3 py-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className={`flex items-center justify-center w-6 h-6 rounded-md shrink-0 ${s.updateAvailable && !s.updateStatus ? 'bg-warning/10' : 'bg-muted'}`}>
|
||||
{s.type === 'local'
|
||||
? <Monitor className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
|
||||
: <Globe className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
|
||||
}
|
||||
</div>
|
||||
<span className="text-sm font-medium truncate">{s.name}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 w-fit">
|
||||
{s.type}
|
||||
</Badge>
|
||||
<span className="text-xs font-mono tabular-nums text-muted-foreground">
|
||||
{formatVersion(s.version) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
||||
</span>
|
||||
<span className="text-xs font-mono tabular-nums">
|
||||
{formatVersion(s.latestVersion) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
||||
</span>
|
||||
<div className="flex justify-end">
|
||||
{s.updateStatus && (
|
||||
<UpdateStatusBadge
|
||||
status={s.updateStatus}
|
||||
error={s.error}
|
||||
onRetry={() => retryNodeUpdate(s.nodeId)}
|
||||
onDismiss={() => dismissNodeUpdate(s.nodeId)}
|
||||
/>
|
||||
)}
|
||||
{!s.updateStatus && !s.updateAvailable && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">
|
||||
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
|
||||
</Badge>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[11px] px-2.5"
|
||||
onClick={() => triggerNodeUpdate(s.nodeId)}
|
||||
disabled={updatingNodeId === s.nodeId}
|
||||
>
|
||||
{updatingNodeId === s.nodeId ? (
|
||||
<><Loader2 className="w-3 h-3 mr-1 animate-spin" />Updating</>
|
||||
) : (
|
||||
<><Download className="w-3 h-3 mr-1" strokeWidth={1.5} />Update</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
No nodes match “{search}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table column header */}
|
||||
<div className="px-6 pb-2 shrink-0">
|
||||
<div className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 px-3 text-[10px] leading-3 font-mono text-stat-subtitle uppercase tracking-[0.18em]">
|
||||
<span>Node</span>
|
||||
<span>Type</span>
|
||||
<span>Current</span>
|
||||
<span>Latest</span>
|
||||
<span className="text-right">Status</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node list — fills remaining height, no cap */}
|
||||
<ScrollArea className="flex-1 min-h-0 px-6">
|
||||
<div className="space-y-1 pb-2">
|
||||
{filtered.map(s => (
|
||||
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 items-center rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className={`flex items-center justify-center w-6 h-6 rounded-md shrink-0 ${s.updateAvailable && !s.updateStatus ? 'bg-warning/10' : 'bg-muted'}`}>
|
||||
{s.type === 'local'
|
||||
? <Monitor className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
|
||||
: <Globe className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
|
||||
}
|
||||
</div>
|
||||
<span className="text-sm font-medium truncate">{s.name}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 w-fit">
|
||||
{s.type}
|
||||
</Badge>
|
||||
<span className="text-xs font-mono tabular-nums text-muted-foreground">
|
||||
{formatVersion(s.version) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
||||
</span>
|
||||
<span className="text-xs font-mono tabular-nums">
|
||||
{formatVersion(s.latestVersion) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
||||
</span>
|
||||
<div className="flex justify-end">
|
||||
{s.updateStatus && (
|
||||
<UpdateStatusBadge
|
||||
status={s.updateStatus}
|
||||
error={s.error}
|
||||
onRetry={() => retryNodeUpdate(s.nodeId)}
|
||||
onDismiss={() => dismissNodeUpdate(s.nodeId)}
|
||||
/>
|
||||
)}
|
||||
{!s.updateStatus && !s.updateAvailable && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">
|
||||
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
|
||||
</Badge>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[11px] px-2.5"
|
||||
onClick={() => triggerNodeUpdate(s.nodeId)}
|
||||
disabled={updatingNodeId === s.nodeId}
|
||||
>
|
||||
{updatingNodeId === s.nodeId ? (
|
||||
<><Loader2 className="w-3 h-3 mr-1 animate-spin" />Updating</>
|
||||
) : (
|
||||
<><Download className="w-3 h-3 mr-1" strokeWidth={1.5} />Update</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
No nodes match “{search}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border/50 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
disabled={recheckingUpdates}
|
||||
onClick={handleRecheck}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 mr-1.5 ${recheckingUpdates ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
||||
Recheck
|
||||
</Button>
|
||||
{canBulkUpdate && updatableRemoteCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={triggerUpdateAll}
|
||||
className="h-7 gap-1.5"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
Update All ({updatableRemoteCount})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</SheetSection>
|
||||
</>
|
||||
)}
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user