mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
feat(frontend): add SystemSheet primitive and migrate mesh sheets to §9.11 chrome (#960)
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.
This commit is contained in:
@@ -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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[600px] sm:max-w-[600px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Mesh activity</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Mesh', 'Activity']}
|
||||
name="Mesh activity"
|
||||
meta={meta}
|
||||
footerContext={footerContext}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Filter by alias, type, or message"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<Input
|
||||
placeholder="Filter by alias, type, or message"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
|
||||
<div className="max-h-[70vh] overflow-auto space-y-1">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-stat-subtitle text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading…
|
||||
</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div className="text-xs text-stat-subtitle">No events.</div>
|
||||
)}
|
||||
{visible.slice().reverse().map((e, i) => (
|
||||
<div key={i} className="grid grid-cols-[80px_70px_120px_1fr] gap-2 text-[11px] font-mono py-1 border-b border-card-border/50">
|
||||
<span className="text-stat-subtitle tabular-nums">{new Date(e.ts).toLocaleTimeString()}</span>
|
||||
<span className={
|
||||
e.level === 'error' ? 'text-destructive uppercase tracking-[0.18em]' :
|
||||
<div className="space-y-1">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-stat-subtitle text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading…
|
||||
</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div className="text-xs text-stat-subtitle">No events.</div>
|
||||
)}
|
||||
{visible.slice().reverse().map((e, i) => (
|
||||
<div key={i} className="grid grid-cols-[80px_70px_120px_1fr] gap-2 text-[11px] font-mono py-1 border-b border-card-border/50">
|
||||
<span className="text-stat-subtitle tabular-nums">{new Date(e.ts).toLocaleTimeString()}</span>
|
||||
<span className={
|
||||
e.level === 'error' ? 'text-destructive uppercase tracking-[0.18em]' :
|
||||
e.level === 'warn' ? 'text-warning uppercase tracking-[0.18em]' :
|
||||
'text-stat-subtitle uppercase tracking-[0.18em]'
|
||||
}>{e.source}</span>
|
||||
<span className="text-stat-value">{e.type}</span>
|
||||
<span className="text-stat-value truncate" title={e.message}>{e.alias ? `[${e.alias}] ` : ''}{e.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
'text-stat-subtitle uppercase tracking-[0.18em]'
|
||||
}>{e.source}</span>
|
||||
<span className="text-stat-value">{e.type}</span>
|
||||
<span className="text-stat-value truncate" title={e.message}>{e.alias ? `[${e.alias}] ` : ''}{e.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<MeshNodeDiagnostic | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[520px] sm:max-w-[520px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<ServerCog className="w-4 h-4" /> Diagnostics{nodeName ? ` · ${nodeName}` : ''}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { void refresh(); }} disabled={loading}>
|
||||
{loading ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <RefreshCw className="w-3 h-3 mr-1" />}
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { void restart(); }} disabled={restarting}>
|
||||
{restarting ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : null}
|
||||
Restart sidecar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 rounded border border-card-border bg-card p-3 text-xs">
|
||||
<div className="text-stat-subtitle">Sidecar</div>
|
||||
<div className="font-mono text-stat-value">{diag?.sidecar.running ? 'running' : 'off'}</div>
|
||||
<div className="text-stat-subtitle">Pilot tunnel</div>
|
||||
<div className="font-mono text-stat-value">{diag?.pilot.connected ? 'connected' : 'disconnected'}</div>
|
||||
<div className="text-stat-subtitle">Buffered</div>
|
||||
<div className="font-mono text-stat-value">{diag ? bytesFmt(diag.pilot.bufferedAmount) : '-'}</div>
|
||||
<div className="text-stat-subtitle">Last seen</div>
|
||||
<div className="font-mono text-stat-value">{diag?.pilot.lastSeen ? new Date(diag.pilot.lastSeen).toLocaleTimeString() : '-'}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.18em] uppercase text-stat-subtitle font-mono mb-2">Active streams</div>
|
||||
{(!diag || diag.activeStreams.length === 0) && (
|
||||
<div className="text-xs text-stat-subtitle">No active streams.</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{diag?.activeStreams.map((s) => (
|
||||
<div key={s.streamId} className="flex justify-between rounded border border-card-border bg-card px-2 py-1 text-[11px] font-mono">
|
||||
<span>#{s.streamId} {s.alias ?? '<no-alias>'}</span>
|
||||
<span className="text-stat-subtitle">in {bytesFmt(s.bytesIn)} / out {bytesFmt(s.bytesOut)} · {ageFmt(s.ageMs)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.18em] uppercase text-stat-subtitle font-mono mb-2">Resolver cache</div>
|
||||
{(!diag || diag.aliasCache.length === 0) && (
|
||||
<div className="text-xs text-stat-subtitle">No aliases registered.</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{diag?.aliasCache.map((a) => (
|
||||
<div key={a.host} className="flex justify-between rounded border border-card-border bg-card px-2 py-1 text-[11px] font-mono">
|
||||
<span>{a.host}</span>
|
||||
<span className="text-stat-subtitle">node #{a.targetNodeId}:{a.port}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Mesh', 'Diagnostics']}
|
||||
name={nodeName ?? 'Diagnostics'}
|
||||
meta={meta}
|
||||
primaryAction={{
|
||||
label: 'Refresh',
|
||||
icon: RefreshCw,
|
||||
onClick: () => { void refresh(); },
|
||||
disabled: loading,
|
||||
}}
|
||||
secondaryActions={[
|
||||
{
|
||||
label: 'Restart sidecar',
|
||||
icon: ServerCog,
|
||||
onClick: () => { void restart(); },
|
||||
disabled: restarting,
|
||||
},
|
||||
]}
|
||||
footerContext={footerContext}
|
||||
size="md"
|
||||
>
|
||||
<SheetSection title="Pilot · sidecar · transport">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
<div className="text-stat-subtitle">Sidecar</div>
|
||||
<div className="font-mono text-stat-value">{diag?.sidecar.running ? 'running' : 'off'}</div>
|
||||
<div className="text-stat-subtitle">Pilot tunnel</div>
|
||||
<div className="font-mono text-stat-value">{diag?.pilot.connected ? 'connected' : 'disconnected'}</div>
|
||||
<div className="text-stat-subtitle">Buffered</div>
|
||||
<div className="font-mono text-stat-value">{diag ? bytesFmt(diag.pilot.bufferedAmount) : '-'}</div>
|
||||
<div className="text-stat-subtitle">Last seen</div>
|
||||
<div className="font-mono text-stat-value">{diag?.pilot.lastSeen ? new Date(diag.pilot.lastSeen).toLocaleTimeString() : '-'}</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title="Active streams">
|
||||
{(!diag || diag.activeStreams.length === 0) && (
|
||||
<div className="text-xs text-stat-subtitle">No active streams.</div>
|
||||
)}
|
||||
<div className="divide-y divide-card-border/40">
|
||||
{diag?.activeStreams.map((s) => (
|
||||
<div key={s.streamId} className="flex justify-between py-1.5 text-[11px] font-mono">
|
||||
<span>#{s.streamId} {s.alias ?? '<no-alias>'}</span>
|
||||
<span className="text-stat-subtitle">in {bytesFmt(s.bytesIn)} / out {bytesFmt(s.bytesOut)} · {ageFmt(s.ageMs)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title="Resolver cache">
|
||||
{(!diag || diag.aliasCache.length === 0) && (
|
||||
<div className="text-xs text-stat-subtitle">No aliases registered.</div>
|
||||
)}
|
||||
<div className="divide-y divide-card-border/40">
|
||||
{diag?.aliasCache.map((a) => (
|
||||
<div key={a.host} className="flex justify-between py-1.5 text-[11px] font-mono">
|
||||
<span>{a.host}</span>
|
||||
<span className="text-stat-subtitle">node #{a.targetNodeId}:{a.port}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SheetSection>
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[420px] sm:max-w-[420px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Mesh stacks on {nodeName}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Adding a stack lets its services be reached from other meshed stacks by hostname.
|
||||
Toggling a stack redeploys it to refresh hostnames.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 space-y-2">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-stat-subtitle text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading stacks…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && stacks.length === 0 && (
|
||||
<div className="text-sm text-stat-subtitle">No stacks deployed on this node yet.</div>
|
||||
)}
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Mesh', nodeName]}
|
||||
name={nodeName}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-stat-subtitle leading-snug">
|
||||
Adding a stack lets its services be reached from other meshed stacks by hostname.
|
||||
Toggling a stack redeploys it to refresh hostnames.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-stat-subtitle text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading stacks…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && stacks.length === 0 && (
|
||||
<div className="text-sm text-stat-subtitle">No stacks deployed on this node yet.</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{stacks.map((stack) => (
|
||||
<div key={stack.name} className="flex items-center justify-between rounded border border-card-border bg-card px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -109,10 +117,7 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<MeshRouteDiagnostic | null>(null);
|
||||
const [events, setEvents] = useState<MeshActivityEvent[]>([]);
|
||||
const [probe, setProbe] = useState<MeshProbeResult | null>(null);
|
||||
const [probing, setProbing] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<RouteTab>('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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[480px] sm:max-w-[480px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="font-mono text-sm">{alias}</SheetTitle>
|
||||
</SheetHeader>
|
||||
const meta = diag?.target
|
||||
? `${diag.target.stack}/${diag.target.service}:${diag.target.port} · node #${diag.target.nodeId}`
|
||||
: (loading ? 'Loading…' : 'No target resolved');
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-sm border text-[10px] leading-3 font-mono uppercase tracking-[0.18em] ${pill.toneClass}`}>
|
||||
{pill.label}
|
||||
</span>
|
||||
{diag?.lastProbeMs != null && (
|
||||
<span className="text-[11px] font-mono text-stat-subtitle">{diag.lastProbeMs}ms</span>
|
||||
)}
|
||||
</div>
|
||||
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 (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Mesh', 'Routes', alias]}
|
||||
name={alias}
|
||||
meta={meta}
|
||||
primaryAction={{
|
||||
label: probing ? 'Probing…' : 'Test probe',
|
||||
icon: probing ? Loader2 : Activity,
|
||||
onClick: () => { 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' && (
|
||||
<>
|
||||
<SheetSection title="State" hideHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-sm border text-[10px] leading-3 font-mono uppercase tracking-[0.18em] ${pill.toneClass}`}>
|
||||
{pill.label}
|
||||
</span>
|
||||
{probe && (
|
||||
<Badge variant={probe.ok ? 'default' : 'destructive'} className="text-[10px] font-mono">
|
||||
{probe.ok ? `ok ${probe.latencyMs}ms` : `${probe.where ?? 'fail'}: ${probe.code ?? 'error'}`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{diag?.target && (
|
||||
<div className="grid grid-cols-2 gap-2 rounded border border-card-border bg-card p-3 text-xs">
|
||||
<div className="text-stat-subtitle">Target node</div>
|
||||
<div className="font-mono text-stat-value">#{diag.target.nodeId}</div>
|
||||
<div className="text-stat-subtitle">Stack / service</div>
|
||||
<div className="font-mono text-stat-value">{diag.target.stack}/{diag.target.service}</div>
|
||||
<div className="text-stat-subtitle">Port</div>
|
||||
<div className="font-mono text-stat-value">{diag.target.port}</div>
|
||||
<div className="text-stat-subtitle">Pilot tunnel</div>
|
||||
<div className="font-mono text-stat-value">{diag.pilot.connected ? 'connected' : 'disconnected'}</div>
|
||||
</div>
|
||||
<SheetSection title="Target">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
<div className="text-stat-subtitle">Target node</div>
|
||||
<div className="font-mono text-stat-value">#{diag.target.nodeId}</div>
|
||||
<div className="text-stat-subtitle">Stack / service</div>
|
||||
<div className="font-mono text-stat-value">{diag.target.stack}/{diag.target.service}</div>
|
||||
<div className="text-stat-subtitle">Port</div>
|
||||
<div className="font-mono text-stat-value">{diag.target.port}</div>
|
||||
<div className="text-stat-subtitle">Pilot tunnel</div>
|
||||
<div className="font-mono text-stat-value">{diag.pilot.connected ? 'connected' : 'disconnected'}</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
{diag?.lastError && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/10 p-3 text-xs">
|
||||
<div className="text-destructive font-mono uppercase tracking-[0.18em] leading-3 text-[10px] mb-1">last error</div>
|
||||
<div className="text-stat-value">{diag.lastError.message}</div>
|
||||
<div className="text-[10px] text-stat-subtitle mt-1">{new Date(diag.lastError.ts).toLocaleString()}</div>
|
||||
</div>
|
||||
<SheetSection title="Last error">
|
||||
<div className="rounded border border-destructive/30 bg-destructive/10 p-3 text-xs">
|
||||
<div className="text-stat-value">{diag.lastError.message}</div>
|
||||
<div className="text-[10px] text-stat-subtitle mt-1">{new Date(diag.lastError.ts).toLocaleString()}</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => { void runProbe(); }} disabled={probing}>
|
||||
{probing ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Activity className="w-3 h-3 mr-1" />}
|
||||
Test upstream
|
||||
</Button>
|
||||
{probe && (
|
||||
<Badge variant={probe.ok ? 'default' : 'destructive'} className="text-[10px] font-mono">
|
||||
{probe.ok ? `ok ${probe.latencyMs}ms` : `${probe.where ?? 'fail'}: ${probe.code ?? 'error'}`}
|
||||
</Badge>
|
||||
{activeTab === 'events' && (
|
||||
<SheetSection title="Recent activity" hideHeader>
|
||||
<div className="space-y-1">
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-stat-subtitle" />}
|
||||
{!loading && events.length === 0 && (
|
||||
<div className="text-xs text-stat-subtitle">No events yet for this alias.</div>
|
||||
)}
|
||||
{events.map((e, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-[11px] font-mono">
|
||||
{e.source === 'sidecar' && <ServerCog className="w-3 h-3 mt-0.5 text-stat-subtitle" />}
|
||||
{e.source === 'pilot' && <Hash className="w-3 h-3 mt-0.5 text-stat-subtitle" />}
|
||||
{e.source === 'mesh' && <Activity className="w-3 h-3 mt-0.5 text-stat-subtitle" />}
|
||||
<span className={`tabular-nums ${e.level === 'error' ? 'text-destructive' : e.level === 'warn' ? 'text-warning' : 'text-stat-value'}`}>
|
||||
{new Date(e.ts).toLocaleTimeString()} {e.type} {e.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] leading-3 tracking-[0.18em] uppercase text-stat-subtitle font-mono mb-2">Recent activity</div>
|
||||
<div className="space-y-1 max-h-72 overflow-auto">
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-stat-subtitle" />}
|
||||
{!loading && events.length === 0 && (
|
||||
<div className="text-xs text-stat-subtitle">No events yet for this alias.</div>
|
||||
)}
|
||||
{events.map((e, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-[11px] font-mono">
|
||||
{e.source === 'sidecar' && <ServerCog className="w-3 h-3 mt-0.5 text-stat-subtitle" />}
|
||||
{e.source === 'pilot' && <Hash className="w-3 h-3 mt-0.5 text-stat-subtitle" />}
|
||||
{e.source === 'mesh' && <Activity className="w-3 h-3 mt-0.5 text-stat-subtitle" />}
|
||||
<span className={`tabular-nums ${e.level === 'error' ? 'text-destructive' : e.level === 'warn' ? 'text-warning' : 'text-stat-value'}`}>
|
||||
{new Date(e.ts).toLocaleTimeString()} {e.type} {e.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{activeTab === 'raw' && (
|
||||
<SheetSection title="Diagnostic JSON" hideHeader>
|
||||
<pre className="text-[11px] font-mono text-stat-value bg-card border border-card-border rounded p-3 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{diag ? JSON.stringify(diag, null, 2) : 'No diagnostic data loaded.'}
|
||||
</pre>
|
||||
</SheetSection>
|
||||
)}
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,12 +51,15 @@ const sheetVariants = cva(
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
VariantProps<typeof sheetVariants> {
|
||||
/** Pass false when the sheet renders its own close affordance (e.g. SystemSheet). */
|
||||
showClose?: boolean
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
>(({ side = "right", className, children, showClose = true, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
@@ -64,10 +67,12 @@ const SheetContent = React.forwardRef<
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{showClose && (
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
|
||||
@@ -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<SystemSheetSize, string> = {
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showClose={false}
|
||||
className={cn(
|
||||
'w-full p-0 flex flex-col gap-0 border-glass-border',
|
||||
SIZE_CLASS[size],
|
||||
)}
|
||||
>
|
||||
<SheetDescription className="sr-only">{crumb.join(' › ')}</SheetDescription>
|
||||
|
||||
<SheetHeaderBand
|
||||
crumb={crumb}
|
||||
name={name}
|
||||
meta={meta}
|
||||
onDismiss={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{hasToolbar && (
|
||||
<ToolbarBand
|
||||
primary={primaryAction}
|
||||
secondaries={secondaryActions}
|
||||
destructive={destructiveAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasTabs && (
|
||||
<TabsBand
|
||||
tabs={tabs}
|
||||
activeTab={activeTab ?? tabs[0].id}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-6 py-5">{children}</div>
|
||||
</ScrollArea>
|
||||
|
||||
{hasFooter && <FooterBand context={footerContext} />}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="relative bg-popover/95 backdrop-blur-md border-b border-card-border/60 px-6 pt-5 pb-4 pr-14">
|
||||
<span aria-hidden className="absolute inset-y-0 left-0 w-[2px] bg-brand" />
|
||||
|
||||
<nav aria-label="Sheet location" className={cn(CRUMB_CLASS, 'flex items-center gap-1.5 leading-none')}>
|
||||
{crumb.map((segment, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === lastIdx;
|
||||
return (
|
||||
<React.Fragment key={`${segment}-${idx}`}>
|
||||
{isFirst ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="hover:text-stat-value transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50 rounded-sm"
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
) : (
|
||||
<span className={cn(isLast && 'text-stat-value')}>{segment}</span>
|
||||
)}
|
||||
{!isLast && <span aria-hidden className="text-stat-subtitle/60">›</span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<SheetTitle className="mt-2 font-display italic text-[1.5rem] leading-tight text-stat-value text-left">
|
||||
{name}
|
||||
</SheetTitle>
|
||||
|
||||
{meta && (
|
||||
<div className="mt-1.5 font-mono text-xs text-stat-subtitle tabular-nums">{meta}</div>
|
||||
)}
|
||||
|
||||
<CloseSlot onDismiss={onDismiss} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseSlot({ onDismiss }: { onDismiss: () => void }) {
|
||||
return (
|
||||
<div className="absolute right-4 top-4 flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="font-mono text-[9px] tracking-[0.18em] uppercase border border-card-border rounded px-1.5 py-0.5 text-stat-subtitle leading-none"
|
||||
>
|
||||
ESC
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Close"
|
||||
className="inline-flex items-center justify-center h-6 w-6 rounded-sm text-stat-subtitle hover:text-stat-value hover:bg-glass-highlight transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50"
|
||||
>
|
||||
<X className="h-4 w-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolbarBandProps {
|
||||
primary?: SystemSheetAction;
|
||||
secondaries?: SystemSheetAction[];
|
||||
destructive?: SystemSheetAction;
|
||||
}
|
||||
|
||||
function ToolbarBand({ primary, secondaries, destructive }: ToolbarBandProps) {
|
||||
return (
|
||||
<div className="bg-popover/95 backdrop-blur-md flex items-center gap-2 border-b border-card-border/60 px-6 py-3">
|
||||
{primary && (
|
||||
<Button size="sm" onClick={primary.onClick} disabled={primary.disabled} className="gap-1.5">
|
||||
{primary.icon && <primary.icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
{primary.label}
|
||||
</Button>
|
||||
)}
|
||||
{secondaries?.map((action, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{action.icon && <action.icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
<span className="flex-1" />
|
||||
{destructive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={destructive.onClick}
|
||||
disabled={destructive.disabled}
|
||||
className="gap-1.5 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
{destructive.icon && <destructive.icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
{destructive.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabsBandProps {
|
||||
tabs: SystemSheetTab[];
|
||||
activeTab: string;
|
||||
onTabChange?: (id: string) => void;
|
||||
}
|
||||
|
||||
function TabsBand({ tabs, activeTab, onTabChange }: TabsBandProps) {
|
||||
return (
|
||||
<div role="tablist" className="bg-popover/95 backdrop-blur-md flex items-stretch gap-0 border-b border-card-border/60 px-4">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onTabChange?.(tab.id)}
|
||||
className={cn(
|
||||
'relative inline-flex items-center gap-1.5 px-3 py-2.5',
|
||||
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50',
|
||||
isActive ? 'text-stat-value' : 'text-stat-subtitle hover:text-stat-value',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span className="font-mono text-[10px] tabular-nums text-stat-subtitle">{tab.count}</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<span aria-hidden className="pointer-events-none absolute inset-x-2 -bottom-px h-[2px] bg-brand" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterBand({ context }: { context: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-popover/95 backdrop-blur-md border-t border-card-border/60 px-6 py-3">
|
||||
<div className={cn(KICKER_CLASS, 'text-stat-subtitle leading-none')}>{context}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className={cn('first:pt-0 first:border-t-0 -mx-6 px-6 py-4 border-t border-card-border/40', className)}>
|
||||
{!hideHeader && (
|
||||
<h3 className={cn(KICKER_CLASS, 'text-stat-subtitle mb-3 leading-none')}>{title}</h3>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user