mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
81858a0bb0
* feat(mesh): refine the Routing tab and add per-route removal
Tighten the Fleet > Routing tab so its node cards and diagnostics read
honestly for proxy-connected fleets, and remove a couple of dead ends.
- Node cards drop the "pilot connected/offline" line, which was meaningless
for nodes that connect over the HTTP API proxy. The compact card drops the
matching agent cell and now reads stacks / aliases / bridge.
- Enabling mesh on a proxy node shows a transient "Connecting" state and
settles to meshed on its own, instead of flashing "Degraded" with a manual
refresh while the bridge finishes dialing.
- The alias detail sheet gains a "Remove from mesh" action that opts the
alias's owning stack out (the confirmation says how many aliases that
drops), and a "Topology" tab. The standalone topology sheet and its
opt-in-sheet shortcut are removed in favor of the tab.
- The "Add stack" affordance stays reachable on a meshed node, so a node
that already has aliases is no longer a dead end.
- Diagnostics and the alias detail sheet show a transport-aware line
("local", "API proxy bridge", or "Pilot tunnel" with its state) instead of
a fixed "Pilot tunnel" label.
Adds unit coverage for the new state derivation, transport descriptor, the
enable auto-converge, and the admin-gated removal, and updates the mesh docs.
* fix(mesh): harden Routing tab toggle and alias sheet against async races
Independent review surfaced two narrow async races in the new code.
- RoutingNodeCard: cancel the converge re-poll batch at the start of any
toggle (so a slow disable can't let a prior enable's re-polls fire), and
guard the toast, refresh, timer scheduling, and setToggling behind a mounted
ref so nothing runs after the card unmounts while the enable POST is still
in flight.
- MeshRouteDetailSheet: re-check the cancelled flag after reading the
diagnostic body, not just before it. Reading the body is itself async, so a
superseded alias's diagnostic could otherwise call setDiag and expose Remove
for the wrong stack.
Adds tests for unmount-before-enable-resolves and the deferred-diagnostic
alias switch.
260 lines
12 KiB
TypeScript
260 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { formatTimeAgo } from '@/lib/relativeTime';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { ConfirmModal } from '@/components/ui/modal';
|
|
import { Loader2, Activity, Hash, Trash2 } from 'lucide-react';
|
|
import type { MeshAlias, MeshNodeStatus, MeshRouteDiagnostic, MeshActivityEvent, MeshProbeResult } from '@/types/mesh';
|
|
import { meshRouteStateFromBackend, meshRouteStateTokens } from './meshRouteState';
|
|
import { describeTransport } from './meshTransport';
|
|
import { MeshStackTopologyView } from './MeshStackTopologyView';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
alias: string | null;
|
|
/** Removing a route opts its owning stack out of the mesh; admin-only, mirrors the backend gate. */
|
|
canManage: boolean;
|
|
status: MeshNodeStatus[];
|
|
aliases: MeshAlias[];
|
|
onChanged: () => void;
|
|
}
|
|
|
|
type RouteTab = 'overview' | 'events' | 'topology' | 'raw';
|
|
|
|
export function MeshRouteDetailSheet({ open, onOpenChange, alias, canManage, status, aliases, onChanged }: 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');
|
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
|
const [removing, setRemoving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open || !alias) return;
|
|
// Reset per-alias state so a stale target can never drive the destructive
|
|
// remove action (or a misleading transport row) during the refetch window.
|
|
setDiag(null);
|
|
setProbe(null);
|
|
setEvents([]);
|
|
let cancelled = false;
|
|
const refresh = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [diagRes, evRes] = await Promise.all([
|
|
apiFetch(`/mesh/aliases/${encodeURIComponent(alias)}/diagnostic`, { localOnly: true }),
|
|
apiFetch(`/mesh/activity?alias=${encodeURIComponent(alias)}&limit=20`, { localOnly: true }),
|
|
]);
|
|
// Parse both bodies before any state write, then re-check
|
|
// `cancelled` after every await. Reading the body is itself async,
|
|
// so a check before `json()` would still let a superseded alias's
|
|
// diagnostic call setDiag and expose Remove for the wrong stack.
|
|
const diagBody = diagRes.ok ? await diagRes.json() as MeshRouteDiagnostic : null;
|
|
const evBody = evRes.ok ? await evRes.json() as { events: MeshActivityEvent[] } : null;
|
|
if (cancelled) return;
|
|
if (diagBody) setDiag(diagBody);
|
|
if (evBody) setEvents(evBody.events);
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
};
|
|
void refresh();
|
|
return () => { cancelled = true; };
|
|
}, [open, alias]);
|
|
|
|
const runProbe = async () => {
|
|
if (!alias) return;
|
|
setProbing(true);
|
|
setProbe(null);
|
|
try {
|
|
const res = await apiFetch(`/mesh/aliases/${encodeURIComponent(alias)}/test`, {
|
|
method: 'POST', localOnly: true,
|
|
});
|
|
const body = await res.json() as MeshProbeResult;
|
|
setProbe(body);
|
|
} finally {
|
|
setProbing(false);
|
|
}
|
|
};
|
|
|
|
const removeFromMesh = async () => {
|
|
const tgt = diag?.target;
|
|
if (!tgt) return;
|
|
setRemoving(true);
|
|
try {
|
|
const res = await apiFetch(
|
|
`/mesh/nodes/${tgt.nodeId}/stacks/${encodeURIComponent(tgt.stack)}/opt-out`,
|
|
{ method: 'POST', localOnly: true },
|
|
);
|
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
|
toast.success(`${tgt.stack} removed from mesh, redeploying`);
|
|
onChanged();
|
|
onOpenChange(false);
|
|
} catch (err) {
|
|
toast.error(`Failed to remove from mesh: ${(err as Error).message}`);
|
|
} finally {
|
|
setRemoving(false);
|
|
}
|
|
};
|
|
|
|
if (!alias) return null;
|
|
const pillState = diag ? meshRouteStateFromBackend(diag.state) : 'not-authorized';
|
|
const pill = meshRouteStateTokens(pillState);
|
|
|
|
const meta = diag?.target
|
|
? `${diag.target.stack}/${diag.target.service}:${diag.target.port} · node #${diag.target.nodeId}`
|
|
: (loading ? 'Loading…' : 'No target resolved');
|
|
|
|
const footerContext = probe
|
|
? (probe.ok ? `Last probe ok · ${probe.latencyMs}ms` : `Last probe failed · ${probe.where ?? 'unknown'}`)
|
|
: diag?.lastProbeMs != null
|
|
? (diag.lastProbeAt != null
|
|
? `Last probe ${formatTimeAgo(diag.lastProbeAt)} · ${diag.lastProbeMs}ms`
|
|
: `Last probe ${diag.lastProbeMs}ms`)
|
|
: 'No probe run yet';
|
|
|
|
const target = diag?.target ?? null;
|
|
const targetNode = target ? status.find((s) => s.nodeId === target.nodeId) : undefined;
|
|
const stackAliasCount = target
|
|
? aliases.filter((a) => a.nodeId === target.nodeId && a.stackName === target.stack).length
|
|
: 0;
|
|
const transport = describeTransport(targetNode, diag?.pilot.connected ?? false);
|
|
|
|
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,
|
|
}}
|
|
destructiveAction={canManage && target ? {
|
|
label: removing ? 'Removing…' : 'Remove from mesh',
|
|
icon: Trash2,
|
|
onClick: () => setConfirmRemove(true),
|
|
disabled: removing,
|
|
} : undefined}
|
|
tabs={[
|
|
{ id: 'overview', label: 'Overview' },
|
|
{ id: 'events', label: 'Events', count: events.length },
|
|
{ id: 'topology', label: 'Topology' },
|
|
{ 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 && (
|
|
<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">{transport.label}</div>
|
|
<div className="font-mono text-stat-value">{transport.value}</div>
|
|
</div>
|
|
</SheetSection>
|
|
)}
|
|
|
|
{diag?.lastError && (
|
|
<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>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{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 === '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>
|
|
)}
|
|
|
|
{activeTab === 'topology' && (
|
|
<SheetSection title="Stack topology" hideHeader>
|
|
{diag?.target ? (
|
|
<MeshStackTopologyView
|
|
nodeId={diag.target.nodeId}
|
|
stackName={diag.target.stack}
|
|
status={status}
|
|
aliases={aliases}
|
|
/>
|
|
) : (
|
|
<div className="text-xs text-stat-subtitle">No target resolved for this alias.</div>
|
|
)}
|
|
</SheetSection>
|
|
)}
|
|
|
|
{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>
|
|
<ConfirmModal
|
|
open={confirmRemove}
|
|
onOpenChange={(o) => { if (!o) setConfirmRemove(false); }}
|
|
variant="destructive"
|
|
kicker={`Mesh / ${target?.stack ?? ''}`}
|
|
title={`Remove ${target?.stack ?? 'stack'} from the mesh?`}
|
|
description={
|
|
target
|
|
? `${target.stack} will be redeployed on node #${target.nodeId} so its containers drop the mesh routing entries. This removes ${stackAliasCount} ${stackAliasCount === 1 ? 'alias' : 'aliases'} published by this stack.`
|
|
: undefined
|
|
}
|
|
confirmLabel="Remove and redeploy"
|
|
onConfirm={() => { setConfirmRemove(false); void removeFromMesh(); }}
|
|
onCancel={() => setConfirmRemove(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|