mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +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.
206 lines
8.0 KiB
TypeScript
206 lines
8.0 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { formatAgeShort } from '@/lib/relativeTime';
|
|
import type { MeshAlias, MeshNodeStatus } from '@/types/mesh';
|
|
import {
|
|
RoutingNodeCard as RoutingNodeCardPrimitive,
|
|
type RoutingAliasRow,
|
|
type RoutingNodeCardMeta,
|
|
type RoutingNodeState,
|
|
} from '@/components/ui/routing-node-card';
|
|
import { deriveNodeState } from './routingNodeState';
|
|
|
|
interface Props {
|
|
status: MeshNodeStatus;
|
|
aliases: MeshAlias[];
|
|
onAddStack: () => void;
|
|
onShowDiagnostics: () => void;
|
|
onShowAlias: (alias: string) => void;
|
|
onTestUpstream: (alias: string) => Promise<void>;
|
|
onChanged: () => void;
|
|
canManage: boolean;
|
|
}
|
|
|
|
const REVERSE_BRIDGE: Record<MeshNodeStatus['reverseCallbackStatus'], RoutingNodeCardMeta['reverseBridge']> = {
|
|
connected: 'up',
|
|
connecting: 'unavailable',
|
|
unavailable: 'unavailable',
|
|
not_applicable: 'na',
|
|
};
|
|
|
|
function buildFooterContext(
|
|
nodeState: RoutingNodeState,
|
|
status: MeshNodeStatus,
|
|
seenAgeMs: number,
|
|
): string {
|
|
const seen = formatAgeShort(seenAgeMs);
|
|
switch (nodeState) {
|
|
case 'meshed': {
|
|
const reverse = status.reverseCallbackStatus === 'connected'
|
|
? 'up'
|
|
: status.reverseCallbackStatus === 'not_applicable'
|
|
? 'n/a'
|
|
: 'unavail';
|
|
return `Reverse bridge ${reverse} · reconcile ${seen}`;
|
|
}
|
|
case 'idle':
|
|
return `Mesh off · seen ${seen}`;
|
|
case 'connecting':
|
|
return `Bringing up bridge · ${seen}`;
|
|
case 'degraded':
|
|
return `Last seen ${seen}`;
|
|
case 'offline':
|
|
return `Last seen ${seen}`;
|
|
default: {
|
|
const _exhaustive: never = nodeState;
|
|
throw new Error(`Unhandled routing node state: ${String(_exhaustive)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function RoutingNodeCard({
|
|
status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, canManage,
|
|
}: Props) {
|
|
const [toggling, setToggling] = useState(false);
|
|
const [testingAlias, setTestingAlias] = useState<string | null>(null);
|
|
const [lastTestedByHost, setLastTestedByHost] = useState<Map<string, { at: number; ok: boolean }>>(() => new Map());
|
|
const lastSeenRef = useRef<number>(Date.now());
|
|
const lastStatusSignatureRef = useRef<string>('');
|
|
const mountedRef = useRef(true);
|
|
// Short re-poll timers fired after an enable so the card converges to
|
|
// `meshed` once the proxy bridge finishes dialing (see toggleEnabled).
|
|
const convergeTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
const clearConvergeTimers = () => {
|
|
convergeTimersRef.current.forEach(clearTimeout);
|
|
convergeTimersRef.current = [];
|
|
};
|
|
useEffect(() => () => {
|
|
mountedRef.current = false;
|
|
convergeTimersRef.current.forEach(clearTimeout);
|
|
convergeTimersRef.current = [];
|
|
}, []);
|
|
|
|
// Track only health-bearing fields; `activeStreamCount` and stack-count
|
|
// churn would otherwise reset the "seen" clock on every reconcile tick.
|
|
const signature = `${status.enabled}|${status.reachableMode}|${status.pilotConnected}|${status.reverseCallbackStatus}`;
|
|
if (signature !== lastStatusSignatureRef.current) {
|
|
lastStatusSignatureRef.current = signature;
|
|
lastSeenRef.current = Date.now();
|
|
}
|
|
|
|
const nodeAliases = useMemo(() => aliases.filter((a) => a.nodeId === status.nodeId), [aliases, status.nodeId]);
|
|
// Defensive de-dup: `/mesh/status` and `/mesh/aliases` are fetched separately,
|
|
// so a transient inconsistency could otherwise render both a live row and a
|
|
// suspended row for the same stack. Drop suspended entries the alias snapshot
|
|
// still covers; the alias row already represents the live state.
|
|
const stackNamesWithAliases = useMemo(
|
|
() => new Set(nodeAliases.map((a) => a.stackName)),
|
|
[nodeAliases],
|
|
);
|
|
const suspended = useMemo(
|
|
() => status.optedInStacks.filter(
|
|
(s) => !s.currentlyResolvable && !stackNamesWithAliases.has(s.stackName),
|
|
),
|
|
[status.optedInStacks, stackNamesWithAliases],
|
|
);
|
|
|
|
const rows: RoutingAliasRow[] = useMemo(() => {
|
|
const live: RoutingAliasRow[] = nodeAliases.map((a) => ({
|
|
host: a.host,
|
|
port: a.port,
|
|
kind: 'alias',
|
|
lastTested: lastTestedByHost.get(a.host),
|
|
testing: testingAlias === a.host,
|
|
}));
|
|
const sus: RoutingAliasRow[] = suspended.map((s) => ({
|
|
host: s.stackName,
|
|
port: 0,
|
|
kind: 'suspended',
|
|
}));
|
|
return [...live, ...sus];
|
|
}, [nodeAliases, suspended, lastTestedByHost, testingAlias]);
|
|
|
|
const nodeState = deriveNodeState(status);
|
|
const meta: RoutingNodeCardMeta = {
|
|
reverseBridge: REVERSE_BRIDGE[status.reverseCallbackStatus],
|
|
stacks: status.optedInStacks.length,
|
|
aliases: nodeAliases.length,
|
|
};
|
|
const seenAgeMs = Date.now() - lastSeenRef.current;
|
|
const footerContext = buildFooterContext(nodeState, status, seenAgeMs);
|
|
|
|
const toggleEnabled = async (next: boolean) => {
|
|
if (toggling) return;
|
|
// Cancel any in-flight converge batch up front, so a slow disable (or a
|
|
// re-toggle) can never let a prior enable's re-polls fire mid-request.
|
|
clearConvergeTimers();
|
|
setToggling(true);
|
|
try {
|
|
const action = next ? 'enable' : 'disable';
|
|
const res = await apiFetch(`/mesh/nodes/${status.nodeId}/${action}`, {
|
|
method: 'POST', localOnly: true,
|
|
});
|
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
|
// The request can resolve after the card unmounts; bail before any
|
|
// toast, refresh, or timer scheduling so nothing fires post-unmount.
|
|
if (!mountedRef.current) return;
|
|
toast.success(next ? 'Mesh enabled on node' : 'Mesh disabled on node');
|
|
onChanged();
|
|
if (next) {
|
|
// The proxy bridge dials asynchronously on enable, so the first
|
|
// status poll usually still reports `connecting`. Re-poll a few
|
|
// times so the card settles to meshed on its own rather than
|
|
// stranding the user on a manual refresh.
|
|
convergeTimersRef.current = [1500, 3500, 6000].map((ms) => setTimeout(onChanged, ms));
|
|
}
|
|
} catch (err) {
|
|
if (!mountedRef.current) return;
|
|
toast.error(`Failed to ${next ? 'enable' : 'disable'} mesh: ${(err as Error).message}`);
|
|
} finally {
|
|
if (mountedRef.current) setToggling(false);
|
|
}
|
|
};
|
|
|
|
const handleTestAlias = async (host: string) => {
|
|
if (testingAlias) return;
|
|
setTestingAlias(host);
|
|
try {
|
|
await onTestUpstream(host);
|
|
setLastTestedByHost((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(host, { at: Date.now(), ok: true });
|
|
return next;
|
|
});
|
|
} catch {
|
|
setLastTestedByHost((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(host, { at: Date.now(), ok: false });
|
|
return next;
|
|
});
|
|
} finally {
|
|
setTestingAlias(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<RoutingNodeCardPrimitive
|
|
crumb={['Routing', 'Node', status.nodeName]}
|
|
name={status.nodeName}
|
|
isLocal={status.reachableMode === 'local'}
|
|
nodeState={nodeState}
|
|
meta={meta}
|
|
aliases={rows}
|
|
onToggleEnabled={(next) => { void toggleEnabled(next); }}
|
|
onShowDiagnostics={onShowDiagnostics}
|
|
onShowAlias={onShowAlias}
|
|
onTestAlias={(host) => { void handleTestAlias(host); }}
|
|
onAddStack={onAddStack}
|
|
onRetry={onChanged}
|
|
footerContext={footerContext}
|
|
offlineReason={status.reachableReason}
|
|
canManage={canManage}
|
|
/>
|
|
);
|
|
}
|