Files
sencho/frontend/src/components/fleet/MeshOptInSheet.tsx
T
Anso 4d9617a5c6 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.
2026-05-06 23:18:40 -04:00

124 lines
5.3 KiB
TypeScript

import { useEffect, useState } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SystemSheet } from '@/components/ui/system-sheet';
import { Checkbox } from '@/components/ui/checkbox';
import type { MeshStackEntry } from '@/types/mesh';
import { Loader2 } from 'lucide-react';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
nodeId: number;
nodeName: string;
onChanged: () => void;
}
export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged }: Props) {
const [stacks, setStacks] = useState<MeshStackEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingStack, setPendingStack] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const res = await apiFetch(`/mesh/nodes/${nodeId}/stacks`, { localOnly: true });
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json() as { stacks: MeshStackEntry[] };
if (!cancelled) setStacks(data.stacks);
} catch (err) {
if (!cancelled) setError((err as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [open, nodeId]);
const toggle = async (stack: MeshStackEntry) => {
setPendingStack(stack.name);
setError(null);
try {
const action = stack.optedIn ? 'opt-out' : 'opt-in';
const res = await apiFetch(
`/mesh/nodes/${nodeId}/stacks/${encodeURIComponent(stack.name)}/${action}`,
{ method: 'POST', localOnly: true },
);
if (res.status === 409) {
const body = await res.json().catch(() => ({})) as { error?: string };
setError(body.error || 'Port already claimed by another mesh stack');
return;
}
if (!res.ok) throw new Error(`status ${res.status}`);
setStacks((prev) => prev.map((s) => s.name === stack.name ? { ...s, optedIn: !stack.optedIn } : s));
onChanged();
toast.success(stack.optedIn ? 'Stack removed from mesh' : 'Stack added to mesh');
} catch (err) {
setError((err as Error).message);
toast.error('Mesh update failed');
} finally {
setPendingStack(null);
}
};
const inMeshCount = stacks.filter((s) => s.optedIn).length;
const meta = `${inMeshCount} of ${stacks.length} in mesh`;
return (
<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">
<Checkbox
id={`mesh-stack-${stack.name}`}
checked={stack.optedIn}
disabled={pendingStack === stack.name}
onCheckedChange={() => { void toggle(stack); }}
/>
<label htmlFor={`mesh-stack-${stack.name}`} className="text-sm font-mono">{stack.name}</label>
</div>
{pendingStack === stack.name && <Loader2 className="w-3 h-3 animate-spin text-stat-subtitle" />}
{stack.optedIn && pendingStack !== stack.name && (
<span className="text-[10px] leading-3 tracking-[0.18em] uppercase text-success/80 font-mono">in mesh</span>
)}
</div>
))}
</div>
</div>
</SystemSheet>
);
}