From 4d9617a5c6a57d873f5438bec665a5e6dd6dcbbb Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 6 May 2026 23:18:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20add=20SystemSheet=20primitive?= =?UTF-8?q?=20and=20migrate=20mesh=20sheets=20to=20=C2=A79.11=20chrome=20(?= =?UTF-8?q?#960)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + in frontend/src/components/ui/system-sheet.tsx, composing the existing / 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. --- .../components/fleet/MeshActivitySheet.tsx | 85 ++--- .../components/fleet/MeshDiagnosticsSheet.tsx | 145 +++++---- .../src/components/fleet/MeshOptInSheet.tsx | 65 ++-- .../components/fleet/MeshRouteDetailSheet.tsx | 157 +++++---- frontend/src/components/ui/sheet.tsx | 17 +- frontend/src/components/ui/system-sheet.tsx | 303 ++++++++++++++++++ 6 files changed, 570 insertions(+), 202 deletions(-) create mode 100644 frontend/src/components/ui/system-sheet.tsx diff --git a/frontend/src/components/fleet/MeshActivitySheet.tsx b/frontend/src/components/fleet/MeshActivitySheet.tsx index 2bf6d883..ffb05a3f 100644 --- a/frontend/src/components/fleet/MeshActivitySheet.tsx +++ b/frontend/src/components/fleet/MeshActivitySheet.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; import { apiFetch } from '@/lib/api'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { SystemSheet } from '@/components/ui/system-sheet'; import { Input } from '@/components/ui/input'; import { Loader2 } from 'lucide-react'; +import { formatTimeAgo } from '@/lib/relativeTime'; import type { MeshActivityEvent } from '@/types/mesh'; interface Props { @@ -42,45 +43,55 @@ export function MeshActivitySheet({ open, onOpenChange }: Props) { ); }); + const mostRecentTs = events.length > 0 ? Math.max(...events.map((e) => e.ts)) : null; + const meta = events.length === 0 + ? '0 events' + : filter + ? `${visible.length} of ${events.length} events` + : `${events.length} events`; + const footerContext = mostRecentTs ? `Last event ${formatTimeAgo(mostRecentTs)}` : 'No events yet'; + return ( - - - - Mesh activity - + +
+ setFilter(e.target.value)} + className="text-xs font-mono" + /> -
- setFilter(e.target.value)} - className="text-xs font-mono" - /> - -
- {loading && ( -
- Loading… -
- )} - {!loading && visible.length === 0 && ( -
No events.
- )} - {visible.slice().reverse().map((e, i) => ( -
- {new Date(e.ts).toLocaleTimeString()} - + {loading && ( +
+ Loading… +
+ )} + {!loading && visible.length === 0 && ( +
No events.
+ )} + {visible.slice().reverse().map((e, i) => ( +
+ {new Date(e.ts).toLocaleTimeString()} + {e.source} - {e.type} - {e.alias ? `[${e.alias}] ` : ''}{e.message} -
- ))} -
+ 'text-stat-subtitle uppercase tracking-[0.18em]' + }>{e.source} + {e.type} + {e.alias ? `[${e.alias}] ` : ''}{e.message} +
+ ))}
- - +
+
); } diff --git a/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx b/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx index c878c21b..52896830 100644 --- a/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx +++ b/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { Button } from '@/components/ui/button'; -import { Loader2, RefreshCw, ServerCog } from 'lucide-react'; +import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; +import { RefreshCw, ServerCog } from 'lucide-react'; +import { formatTimeAgo } from '@/lib/relativeTime'; import type { MeshNodeDiagnostic } from '@/types/mesh'; interface Props { @@ -29,13 +29,17 @@ export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: P const [diag, setDiag] = useState(null); const [loading, setLoading] = useState(false); const [restarting, setRestarting] = useState(false); + const [updatedAt, setUpdatedAt] = useState(null); const refresh = async () => { if (nodeId == null) return; setLoading(true); try { const res = await apiFetch(`/mesh/nodes/${nodeId}/diagnostic`, { localOnly: true }); - if (res.ok) setDiag(await res.json()); + if (res.ok) { + setDiag(await res.json()); + setUpdatedAt(Date.now()); + } } finally { setLoading(false); } @@ -64,69 +68,78 @@ export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: P } }; + const sidecarLabel = diag ? (diag.sidecar.running ? 'sidecar running' : 'sidecar off') : 'sidecar ?'; + const pilotLabel = diag ? (diag.pilot.connected ? 'pilot connected' : 'pilot disconnected') : 'pilot ?'; + const streamsLabel = `${diag?.activeStreams.length ?? 0} streams`; + const aliasesLabel = `${diag?.aliasCache.length ?? 0} aliases`; + const meta = `${sidecarLabel} · ${pilotLabel} · ${streamsLabel} · ${aliasesLabel}`; + + const footerContext = updatedAt ? `Updated ${formatTimeAgo(updatedAt)}` : (loading ? 'Loading…' : 'Never updated'); + return ( - - - - - Diagnostics{nodeName ? ` · ${nodeName}` : ''} - - - -
-
- - -
- -
-
Sidecar
-
{diag?.sidecar.running ? 'running' : 'off'}
-
Pilot tunnel
-
{diag?.pilot.connected ? 'connected' : 'disconnected'}
-
Buffered
-
{diag ? bytesFmt(diag.pilot.bufferedAmount) : '-'}
-
Last seen
-
{diag?.pilot.lastSeen ? new Date(diag.pilot.lastSeen).toLocaleTimeString() : '-'}
-
- -
-
Active streams
- {(!diag || diag.activeStreams.length === 0) && ( -
No active streams.
- )} -
- {diag?.activeStreams.map((s) => ( -
- #{s.streamId} {s.alias ?? ''} - in {bytesFmt(s.bytesIn)} / out {bytesFmt(s.bytesOut)} · {ageFmt(s.ageMs)} -
- ))} -
-
- -
-
Resolver cache
- {(!diag || diag.aliasCache.length === 0) && ( -
No aliases registered.
- )} -
- {diag?.aliasCache.map((a) => ( -
- {a.host} - node #{a.targetNodeId}:{a.port} -
- ))} -
-
+ { void refresh(); }, + disabled: loading, + }} + secondaryActions={[ + { + label: 'Restart sidecar', + icon: ServerCog, + onClick: () => { void restart(); }, + disabled: restarting, + }, + ]} + footerContext={footerContext} + size="md" + > + +
+
Sidecar
+
{diag?.sidecar.running ? 'running' : 'off'}
+
Pilot tunnel
+
{diag?.pilot.connected ? 'connected' : 'disconnected'}
+
Buffered
+
{diag ? bytesFmt(diag.pilot.bufferedAmount) : '-'}
+
Last seen
+
{diag?.pilot.lastSeen ? new Date(diag.pilot.lastSeen).toLocaleTimeString() : '-'}
- - +
+ + + {(!diag || diag.activeStreams.length === 0) && ( +
No active streams.
+ )} +
+ {diag?.activeStreams.map((s) => ( +
+ #{s.streamId} {s.alias ?? ''} + in {bytesFmt(s.bytesIn)} / out {bytesFmt(s.bytesOut)} · {ageFmt(s.ageMs)} +
+ ))} +
+
+ + + {(!diag || diag.aliasCache.length === 0) && ( +
No aliases registered.
+ )} +
+ {diag?.aliasCache.map((a) => ( +
+ {a.host} + node #{a.targetNodeId}:{a.port} +
+ ))} +
+
+
); } diff --git a/frontend/src/components/fleet/MeshOptInSheet.tsx b/frontend/src/components/fleet/MeshOptInSheet.tsx index 87a91ab4..cc709429 100644 --- a/frontend/src/components/fleet/MeshOptInSheet.tsx +++ b/frontend/src/components/fleet/MeshOptInSheet.tsx @@ -1,8 +1,7 @@ import { useEffect, useState } from 'react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'; -import { Button } from '@/components/ui/button'; +import { SystemSheet } from '@/components/ui/system-sheet'; import { Checkbox } from '@/components/ui/checkbox'; import type { MeshStackEntry } from '@/types/mesh'; import { Loader2 } from 'lucide-react'; @@ -67,30 +66,39 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged } }; + const inMeshCount = stacks.filter((s) => s.optedIn).length; + const meta = `${inMeshCount} of ${stacks.length} in mesh`; + return ( - - - - Mesh stacks on {nodeName} - - Adding a stack lets its services be reached from other meshed stacks by hostname. - Toggling a stack redeploys it to refresh hostnames. - - -
- {loading && ( -
- Loading stacks… -
- )} - {error && ( -
- {error} -
- )} - {!loading && stacks.length === 0 && ( -
No stacks deployed on this node yet.
- )} + +
+

+ Adding a stack lets its services be reached from other meshed stacks by hostname. + Toggling a stack redeploys it to refresh hostnames. +

+ + {loading && ( +
+ Loading stacks… +
+ )} + {error && ( +
+ {error} +
+ )} + {!loading && stacks.length === 0 && ( +
No stacks deployed on this node yet.
+ )} + +
{stacks.map((stack) => (
@@ -109,10 +117,7 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
))}
-
- -
- - +
+ ); } diff --git a/frontend/src/components/fleet/MeshRouteDetailSheet.tsx b/frontend/src/components/fleet/MeshRouteDetailSheet.tsx index 979151da..3a89caf5 100644 --- a/frontend/src/components/fleet/MeshRouteDetailSheet.tsx +++ b/frontend/src/components/fleet/MeshRouteDetailSheet.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; import { apiFetch } from '@/lib/api'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { Button } from '@/components/ui/button'; +import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { Badge } from '@/components/ui/badge'; import { Loader2, Activity, ServerCog, Hash } from 'lucide-react'; import type { MeshRouteDiagnostic, MeshActivityEvent, MeshProbeResult } from '@/types/mesh'; @@ -13,12 +12,15 @@ interface Props { alias: string | null; } +type RouteTab = 'overview' | 'events' | 'raw'; + export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) { const [diag, setDiag] = useState(null); const [events, setEvents] = useState([]); const [probe, setProbe] = useState(null); const [probing, setProbing] = useState(false); const [loading, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState('overview'); useEffect(() => { if (!open || !alias) return; @@ -63,77 +65,106 @@ export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) { const pillState = diag ? meshRouteStateFromBackend(diag.state) : 'not-authorized'; const pill = meshRouteStateTokens(pillState); - return ( - - - - {alias} - + const meta = diag?.target + ? `${diag.target.stack}/${diag.target.service}:${diag.target.port} · node #${diag.target.nodeId}` + : (loading ? 'Loading…' : 'No target resolved'); -
-
- - {pill.label} - - {diag?.lastProbeMs != null && ( - {diag.lastProbeMs}ms - )} -
+ const footerContext = probe + ? (probe.ok ? `Last probe ok · ${probe.latencyMs}ms` : `Last probe failed · ${probe.where ?? 'unknown'}`) + : (diag?.lastProbeMs != null ? `Last probe ${diag.lastProbeMs}ms` : 'No probe run yet'); + + return ( + { void runProbe(); }, + disabled: probing, + }} + tabs={[ + { id: 'overview', label: 'Overview' }, + { id: 'events', label: 'Events', count: events.length }, + { id: 'raw', label: 'Raw' }, + ]} + activeTab={activeTab} + onTabChange={(id) => setActiveTab(id as RouteTab)} + footerContext={footerContext} + size="md" + > + {activeTab === 'overview' && ( + <> + +
+ + {pill.label} + + {probe && ( + + {probe.ok ? `ok ${probe.latencyMs}ms` : `${probe.where ?? 'fail'}: ${probe.code ?? 'error'}`} + + )} +
+
{diag?.target && ( -
-
Target node
-
#{diag.target.nodeId}
-
Stack / service
-
{diag.target.stack}/{diag.target.service}
-
Port
-
{diag.target.port}
-
Pilot tunnel
-
{diag.pilot.connected ? 'connected' : 'disconnected'}
-
+ +
+
Target node
+
#{diag.target.nodeId}
+
Stack / service
+
{diag.target.stack}/{diag.target.service}
+
Port
+
{diag.target.port}
+
Pilot tunnel
+
{diag.pilot.connected ? 'connected' : 'disconnected'}
+
+
)} {diag?.lastError && ( -
-
last error
-
{diag.lastError.message}
-
{new Date(diag.lastError.ts).toLocaleString()}
-
+ +
+
{diag.lastError.message}
+
{new Date(diag.lastError.ts).toLocaleString()}
+
+
)} + + )} -
- - {probe && ( - - {probe.ok ? `ok ${probe.latencyMs}ms` : `${probe.where ?? 'fail'}: ${probe.code ?? 'error'}`} - + {activeTab === 'events' && ( + +
+ {loading && } + {!loading && events.length === 0 && ( +
No events yet for this alias.
)} + {events.map((e, i) => ( +
+ {e.source === 'sidecar' && } + {e.source === 'pilot' && } + {e.source === 'mesh' && } + + {new Date(e.ts).toLocaleTimeString()} {e.type} {e.message} + +
+ ))}
+
+ )} -
-
Recent activity
-
- {loading && } - {!loading && events.length === 0 && ( -
No events yet for this alias.
- )} - {events.map((e, i) => ( -
- {e.source === 'sidecar' && } - {e.source === 'pilot' && } - {e.source === 'mesh' && } - - {new Date(e.ts).toLocaleTimeString()} {e.type} {e.message} - -
- ))} -
-
-
- - + {activeTab === 'raw' && ( + +
+                        {diag ? JSON.stringify(diag, null, 2) : 'No diagnostic data loaded.'}
+                    
+
+ )} +
); } diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx index d0d706b9..2ebeb910 100644 --- a/frontend/src/components/ui/sheet.tsx +++ b/frontend/src/components/ui/sheet.tsx @@ -51,12 +51,15 @@ const sheetVariants = cva( interface SheetContentProps extends React.ComponentPropsWithoutRef, - VariantProps {} + VariantProps { + /** Pass false when the sheet renders its own close affordance (e.g. SystemSheet). */ + showClose?: boolean +} const SheetContent = React.forwardRef< React.ElementRef, SheetContentProps ->(({ side = "right", className, children, ...props }, ref) => ( +>(({ side = "right", className, children, showClose = true, ...props }, ref) => ( - - - Close - + {showClose && ( + + + Close + + )} {children} diff --git a/frontend/src/components/ui/system-sheet.tsx b/frontend/src/components/ui/system-sheet.tsx new file mode 100644 index 00000000..1db3b17e --- /dev/null +++ b/frontend/src/components/ui/system-sheet.tsx @@ -0,0 +1,303 @@ +import * as React from 'react'; +import { X, type LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Button } from '@/components/ui/button'; + +const KICKER_CLASS = 'font-mono text-[10px] uppercase tracking-[0.22em]'; +const CRUMB_CLASS = `${KICKER_CLASS} text-stat-subtitle`; + +type SystemSheetSize = 'sm' | 'md' | 'lg' | 'xl'; + +const SIZE_CLASS: Record = { + sm: 'sm:max-w-[420px]', + md: 'sm:max-w-[560px]', + lg: 'sm:max-w-[720px]', + xl: 'sm:max-w-[960px]', +}; + +export interface SystemSheetAction { + label: React.ReactNode; + onClick: () => void; + disabled?: boolean; + icon?: LucideIcon; +} + +export interface SystemSheetTab { + id: string; + label: string; + count?: number; +} + +export interface SystemSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + + // Header + crumb: string[]; + name: React.ReactNode; + meta?: React.ReactNode; + + // Toolbar band (omit entirely when no actions provided) + primaryAction?: SystemSheetAction; + secondaryActions?: SystemSheetAction[]; + destructiveAction?: SystemSheetAction; + + // Tabs band (omit entirely when undefined) + tabs?: SystemSheetTab[]; + activeTab?: string; + onTabChange?: (id: string) => void; + + // Footer freshness band (omit entirely when undefined) + footerContext?: React.ReactNode; + + size?: SystemSheetSize; + children?: React.ReactNode; +} + +export function SystemSheet({ + open, + onOpenChange, + crumb, + name, + meta, + primaryAction, + secondaryActions, + destructiveAction, + tabs, + activeTab, + onTabChange, + footerContext, + size = 'md', + children, +}: SystemSheetProps) { + const hasToolbar = !!(primaryAction || (secondaryActions && secondaryActions.length > 0) || destructiveAction); + const hasTabs = !!(tabs && tabs.length > 0); + const hasFooter = footerContext !== undefined && footerContext !== null; + + return ( + + + {crumb.join(' › ')} + + onOpenChange(false)} + /> + + {hasToolbar && ( + + )} + + {hasTabs && ( + + )} + + +
{children}
+
+ + {hasFooter && } +
+
+ ); +} + +interface SheetHeaderBandProps { + crumb: string[]; + name: React.ReactNode; + meta?: React.ReactNode; + onDismiss: () => void; +} + +function SheetHeaderBand({ crumb, name, meta, onDismiss }: SheetHeaderBandProps) { + const lastIdx = crumb.length - 1; + return ( +
+ + + + + + {name} + + + {meta && ( +
{meta}
+ )} + + +
+ ); +} + +function CloseSlot({ onDismiss }: { onDismiss: () => void }) { + return ( +
+ + ESC + + +
+ ); +} + +interface ToolbarBandProps { + primary?: SystemSheetAction; + secondaries?: SystemSheetAction[]; + destructive?: SystemSheetAction; +} + +function ToolbarBand({ primary, secondaries, destructive }: ToolbarBandProps) { + return ( +
+ {primary && ( + + )} + {secondaries?.map((action, idx) => ( + + ))} + + {destructive && ( + + )} +
+ ); +} + +interface TabsBandProps { + tabs: SystemSheetTab[]; + activeTab: string; + onTabChange?: (id: string) => void; +} + +function TabsBand({ tabs, activeTab, onTabChange }: TabsBandProps) { + return ( +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTab; + return ( + + ); + })} +
+ ); +} + +function FooterBand({ context }: { context: React.ReactNode }) { + return ( +
+
{context}
+
+ ); +} + +export interface SheetSectionProps { + title: string; + /** Hide the title rule + label (useful when a section is the only one in a tab). */ + hideHeader?: boolean; + className?: string; + children: React.ReactNode; +} + +export function SheetSection({ title, hideHeader, className, children }: SheetSectionProps) { + return ( +
+ {!hideHeader && ( +

{title}

+ )} + {children} +
+ ); +}