mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +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.
134 lines
5.9 KiB
TypeScript
134 lines
5.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
|
import { RefreshCw } from 'lucide-react';
|
|
import { formatTimeAgo } from '@/lib/relativeTime';
|
|
import type { MeshNodeDiagnostic, MeshNodeStatus } from '@/types/mesh';
|
|
import { describeTransport } from './meshTransport';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
nodeId: number | null;
|
|
nodeName: string | null;
|
|
/** Routing status for this node; drives the transport-aware diagnostics line. */
|
|
nodeStatus: MeshNodeStatus | null;
|
|
}
|
|
|
|
function bytesFmt(n: number): string {
|
|
if (n < 1024) return `${n}B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
}
|
|
|
|
function ageFmt(ms: number): string {
|
|
if (ms < 1000) return `${ms}ms`;
|
|
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
|
return `${Math.floor(ms / 60_000)}m`;
|
|
}
|
|
|
|
export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName, nodeStatus }: Props) {
|
|
const [diag, setDiag] = useState<MeshNodeDiagnostic | null>(null);
|
|
const [loading, setLoading] = 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());
|
|
setUpdatedAt(Date.now());
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (open) void refresh();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, nodeId]);
|
|
|
|
const transport = describeTransport(nodeStatus ?? undefined, diag?.pilot.connected ?? false);
|
|
const isPilotNode = nodeStatus?.reachableMode === 'pilot';
|
|
const forwarderLabel = diag
|
|
? (diag.forwarder.listening ? `forwarder listening (${diag.forwarder.listenerCount})` : 'forwarder idle')
|
|
: 'forwarder ?';
|
|
const transportLabel = `${transport.label.toLowerCase()} ${transport.value}`;
|
|
const streamsLabel = `${diag?.activeStreams.length ?? 0} streams`;
|
|
const aliasesLabel = `${diag?.aliasCache.length ?? 0} aliases`;
|
|
const meta = `${forwarderLabel} · ${transportLabel} · ${streamsLabel} · ${aliasesLabel}`;
|
|
|
|
const footerContext = updatedAt ? `Updated ${formatTimeAgo(updatedAt)}` : (loading ? 'Loading…' : 'Never updated');
|
|
|
|
return (
|
|
<SystemSheet
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
crumb={['Fleet', 'Mesh', 'Diagnostics']}
|
|
name={nodeName ?? 'Diagnostics'}
|
|
meta={meta}
|
|
primaryAction={{
|
|
label: 'Refresh',
|
|
icon: RefreshCw,
|
|
onClick: () => { void refresh(); },
|
|
disabled: loading,
|
|
}}
|
|
footerContext={footerContext}
|
|
size="md"
|
|
>
|
|
<SheetSection title="Forwarder · transport">
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
|
<div className="text-stat-subtitle">Forwarder</div>
|
|
<div className="font-mono text-stat-value">
|
|
{diag?.forwarder.listening ? `listening on ${diag.forwarder.listenerCount} port${diag.forwarder.listenerCount === 1 ? '' : 's'}` : 'idle'}
|
|
</div>
|
|
<div className="text-stat-subtitle">{transport.label}</div>
|
|
<div className="font-mono text-stat-value">{transport.value}</div>
|
|
{isPilotNode && (
|
|
<>
|
|
<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>
|
|
<p className="text-[11px] text-stat-subtitle leading-snug mt-2">
|
|
Mesh runs in-process on each node; no separate container.
|
|
</p>
|
|
</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>
|
|
);
|
|
}
|