diff --git a/docs/features/sencho-mesh.mdx b/docs/features/sencho-mesh.mdx
index 34d655ca..c926cc4b 100644
--- a/docs/features/sencho-mesh.mdx
+++ b/docs/features/sencho-mesh.mdx
@@ -29,7 +29,7 @@ The user-facing effect is `psql -h db.api.opsix.sencho` from a container on any
**Bidirectional traffic over a single channel.** Mesh traffic is multiplexed onto the same channel Sencho already uses for fleet operations, so a node behind NAT can both receive and originate connections without exposing any new inbound port. The only inbound port that ever matters is the one Sencho itself is already listening on for fleet operations.
-**Live fleet-wide diagnostics.** Every alias has a one-click probe that runs across the real code path. Each node exposes a diagnostics panel showing forwarder liveness, pilot tunnel state, active TCP streams, and the resolver cache. A fleet-wide activity log records routing decisions and tunnel state changes as they happen.
+**Live fleet-wide diagnostics.** Every alias has a one-click probe that runs across the real code path. Each node exposes a diagnostics panel showing forwarder liveness, the node's transport state, active TCP streams, and the resolver cache. A fleet-wide activity log records routing decisions and tunnel state changes as they happen.
## Prerequisites
@@ -52,7 +52,7 @@ Mesh lives under **Fleet → Routing**. Managing it (the per-node mesh toggle an
2. Flip the mesh toggle (`ON` / `OFF`) on each node that should participate.
3. Click **Add stack to mesh** on a node and confirm one or more stacks.
-
+
@@ -83,7 +83,7 @@ Every node card has a **Diagnostics** button that opens a live view.
The sheet shows:
- **Forwarder** state and number of listening ports. The forwarder is the in-process TCP listener Sencho binds for each opted-in service port.
-- **Pilot tunnel** state (`connected` / `disconnected`) and last-seen timestamp. For local diagnostics this reports the local Sencho's own forwarder; for a remote it reports central's view of the tunnel to that remote.
+- **Transport** state, labelled by how the node actually connects: `local (in-process)` for the central node, `API proxy bridge` (`connected` / `connecting` / `unavailable`) for a Distributed API Proxy peer, or `Pilot tunnel` (`connected` / `disconnected`) for a Pilot Agent node. The buffered-bytes and last-seen rows appear only for Pilot Agent nodes, where they apply.
- **Active streams** with byte counters in and out, and how long each stream has been open.
- **Resolver cache** showing the aliases currently registered on this node and the backend `host:port` they resolve to.
@@ -128,7 +128,7 @@ Edge labels:
Click any node card in graph mode to open its opt-in sheet.
-**Per-stack topology.** Inside the opt-in sheet, each opted-in stack row has a **Topology** button that opens a focused diagram for that stack.
+**Per-stack topology.** Click an alias on a node card to open its route detail sheet, then switch to the **Topology** tab for a focused diagram of the stack that publishes that alias.
@@ -154,6 +154,8 @@ Every alias row has a one-click test that runs a real probe along the same code
Use Test before assuming the issue is your application. It tells you whether the mesh path itself is working.
+Clicking the alias itself (rather than its Test button) opens the route detail sheet: the probe, an Events tab, a **Topology** tab for the publishing stack, and a **Remove from mesh** action that opts the alias's owning stack back out of the mesh. When a stack publishes more than one alias, the confirmation says how many go together, since opt-out is per stack.
+
## Customising the mesh subnet
Sencho picks the mesh subnet automatically. When `SENCHO_MESH_SUBNET` is unset and `sencho_mesh` does not already exist on the Docker daemon, the node walks `172.30.0.0/24`, `172.31.0.0/24`, `10.42.0.0/24`, then `10.43.0.0/24` and keeps the first one Docker accepts. When `sencho_mesh` already exists, Sencho adopts its subnet (Docker is the source of truth across restarts). Override the choice per node with `SENCHO_MESH_SUBNET` when you need a specific CIDR:
@@ -232,8 +234,8 @@ These are the explicit boundaries of the v1 mesh.
The mesh enforces one alias per TCP port across the fleet. Another stack on the fleet already publishes a service on the same port. Move one of the services to a different port and redeploy, or leave the second stack out of the mesh.
-
- The Pilot Agent tunnel to that node is not connected. Open **Fleet → Overview** for the node's status and follow the [Pilot Agent troubleshooting](/features/pilot-agent#troubleshooting) entries. Mesh recovers automatically when the tunnel reconnects; no opt-out is needed.
+
+ The transport to that node is not currently up: a Pilot Agent tunnel that dropped, or a Distributed API Proxy bridge that is unavailable. For a Pilot Agent node, open **Fleet → Overview** for the node's status and follow the [Pilot Agent troubleshooting](/features/pilot-agent#troubleshooting) entries. Mesh recovers automatically when the transport reconnects; no opt-out is needed. A node that is actively dialing its bridge shows `Connecting` instead and clears on its own within a few seconds.
diff --git a/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx b/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx
index f7ee6ecc..19ad0cb8 100644
--- a/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx
+++ b/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx
@@ -3,13 +3,16 @@ 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 } from '@/types/mesh';
+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 {
@@ -24,7 +27,7 @@ function ageFmt(ms: number): string {
return `${Math.floor(ms / 60_000)}m`;
}
-export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: Props) {
+export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName, nodeStatus }: Props) {
const [diag, setDiag] = useState(null);
const [loading, setLoading] = useState(false);
const [updatedAt, setUpdatedAt] = useState(null);
@@ -48,13 +51,15 @@ export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: P
// 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 pilotLabel = diag ? (diag.pilot.connected ? 'pilot connected' : 'pilot disconnected') : 'pilot ?';
+ const transportLabel = `${transport.label.toLowerCase()} ${transport.value}`;
const streamsLabel = `${diag?.activeStreams.length ?? 0} streams`;
const aliasesLabel = `${diag?.aliasCache.length ?? 0} aliases`;
- const meta = `${forwarderLabel} · ${pilotLabel} · ${streamsLabel} · ${aliasesLabel}`;
+ const meta = `${forwarderLabel} · ${transportLabel} · ${streamsLabel} · ${aliasesLabel}`;
const footerContext = updatedAt ? `Updated ${formatTimeAgo(updatedAt)}` : (loading ? 'Loading…' : 'Never updated');
@@ -74,18 +79,22 @@ export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: P
footerContext={footerContext}
size="md"
>
-
+
@@ -170,5 +239,21 @@ export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) {
)}
+ { 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)}
+ />
+ >
);
}
diff --git a/frontend/src/components/fleet/MeshStackTopologySheet.test.tsx b/frontend/src/components/fleet/MeshStackTopologyView.test.tsx
similarity index 51%
rename from frontend/src/components/fleet/MeshStackTopologySheet.test.tsx
rename to frontend/src/components/fleet/MeshStackTopologyView.test.tsx
index 3ebb2582..20361898 100644
--- a/frontend/src/components/fleet/MeshStackTopologySheet.test.tsx
+++ b/frontend/src/components/fleet/MeshStackTopologyView.test.tsx
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
-import { MeshStackTopologySheet } from './MeshStackTopologySheet';
+import { MeshStackTopologyView } from './MeshStackTopologyView';
import type { MeshAlias, MeshNodeStatus } from '@/types/mesh';
vi.mock('@xyflow/react', () => ({
- ReactFlow: () => null,
+ ReactFlow: () => ,
Background: () => null,
Handle: () => null,
Position: { Left: 'left', Right: 'right' },
@@ -40,67 +40,25 @@ function makeAlias(over: Partial & Pick
const baseStatus: MeshNodeStatus[] = [
makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
- makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'pilot', enabled: true, pilotConnected: true }),
+ makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'proxy', enabled: true, reverseCallbackStatus: 'connected' }),
];
-describe('MeshStackTopologySheet', () => {
+describe('MeshStackTopologyView', () => {
it('renders the empty-state message when the stack publishes no aliases', () => {
- render(
- {}}
- nodeId={1}
- nodeName="local"
- stackName="api"
- status={baseStatus}
- aliases={[]}
- />,
- );
+ render();
expect(screen.getByText(/No published mesh services/i)).toBeInTheDocument();
- expect(screen.getByText(/0 aliases/i)).toBeInTheDocument();
+ expect(screen.queryByTestId('reactflow')).not.toBeInTheDocument();
});
- it('shows the alias and consumer counts in the meta band', () => {
+ it('renders the topology graph when the stack publishes aliases', () => {
const aliases = [
makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' }),
makeAlias({ host: 'b.mesh', nodeId: 1, stackName: 'api' }),
];
- render(
- {}}
- nodeId={1}
- nodeName="local"
- stackName="api"
- status={baseStatus}
- aliases={aliases}
- />,
- );
+ render();
- expect(screen.getByText(/2 aliases · 1 consumer/i)).toBeInTheDocument();
+ expect(screen.getByTestId('reactflow')).toBeInTheDocument();
expect(screen.queryByText(/No published mesh services/i)).not.toBeInTheDocument();
});
-
- it('counts only meshed remote nodes as consumers', () => {
- const aliases = [makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' })];
- const status: MeshNodeStatus[] = [
- makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
- makeNode({ nodeId: 2, nodeName: 'meshed', reachableMode: 'pilot', enabled: true, pilotConnected: true }),
- makeNode({ nodeId: 3, nodeName: 'unmeshed', reachableMode: 'pilot', enabled: false }),
- ];
- render(
- {}}
- nodeId={1}
- nodeName="local"
- stackName="api"
- status={status}
- aliases={aliases}
- />,
- );
-
- expect(screen.getByText(/1 alias · 1 consumer/i)).toBeInTheDocument();
- });
});
diff --git a/frontend/src/components/fleet/MeshStackTopologySheet.tsx b/frontend/src/components/fleet/MeshStackTopologyView.tsx
similarity index 68%
rename from frontend/src/components/fleet/MeshStackTopologySheet.tsx
rename to frontend/src/components/fleet/MeshStackTopologyView.tsx
index d2b74922..b23c215c 100644
--- a/frontend/src/components/fleet/MeshStackTopologySheet.tsx
+++ b/frontend/src/components/fleet/MeshStackTopologyView.tsx
@@ -13,15 +13,11 @@ import {
import '@xyflow/react/dist/style.css';
import { Boxes, Globe, Server, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
-import { SystemSheet } from '@/components/ui/system-sheet';
import { buildStackTopologyGraph } from '@/lib/mesh-topology-layout';
import type { MeshAlias, MeshNodeStatus } from '@/types/mesh';
interface Props {
- open: boolean;
- onOpenChange: (open: boolean) => void;
nodeId: number | null;
- nodeName: string | null;
stackName: string | null;
status: MeshNodeStatus[];
aliases: MeshAlias[];
@@ -119,15 +115,12 @@ const nodeTypes: NodeTypes = {
stackConsumer: StackConsumerCard,
};
-export function MeshStackTopologySheet({
- open,
- onOpenChange,
- nodeId,
- nodeName,
- stackName,
- status,
- aliases,
-}: Props) {
+/**
+ * Read-only ReactFlow view of a single stack's published aliases and the meshed
+ * peers that can reach them. Rendered inside the alias route sheet's Topology
+ * tab; carries no sheet chrome of its own.
+ */
+export function MeshStackTopologyView({ nodeId, stackName, status, aliases }: Props) {
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState([]);
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState([]);
@@ -169,64 +162,46 @@ export function MeshStackTopologySheet({
return aliases.filter((a) => a.nodeId === nodeId && a.stackName === stackName).length;
}, [nodeId, stackName, aliases]);
- const consumerCount = useMemo(() => {
- if (nodeId === null) return 0;
- return status.filter((s) => s.enabled && s.nodeId !== nodeId).length;
- }, [nodeId, status]);
-
- const meta = `${stackAliasCount} ${stackAliasCount === 1 ? 'alias' : 'aliases'} · ${consumerCount} ${consumerCount === 1 ? 'consumer' : 'consumers'}`;
- const crumbName = stackName ?? '';
- const ownerName = nodeName ?? '';
-
return (
-
-
-
- Aliases this stack publishes and the meshed nodes that can reach them. Edge styling
- reflects each consumer's tunnel state.
-
-
- Consumer nodes are meshed peers that could reach this stack's aliases via DNS.
- Whether a container on a consumer actually dials an alias depends on that consumer's
- own opt-in stacks.
-
+
+
+ Aliases this stack publishes and the meshed nodes that can reach them. Edge styling
+ reflects each consumer's tunnel state.
+
+
+ Consumer nodes are meshed peers that could reach this stack's aliases via DNS.
+ Whether a container on a consumer actually dials an alias depends on that consumer's
+ own opt-in stacks.
+
- {stackAliasCount === 0 ? (
-
-
-
No published mesh services
-
- This stack is in the mesh but exposes no service ports for other meshed stacks to reach.
-
+ {stackAliasCount === 0 ? (
+
+
+
No published mesh services
+
+ This stack is in the mesh but exposes no service ports for other meshed stacks to reach.
- ) : (
-
-
-
-
-
-
+
+ ) : (
+
+
+
+
+
- )}
-
-
+
+ )}
+
);
}
diff --git a/frontend/src/components/fleet/RoutingNodeCard.test.tsx b/frontend/src/components/fleet/RoutingNodeCard.test.tsx
new file mode 100644
index 00000000..441cc532
--- /dev/null
+++ b/frontend/src/components/fleet/RoutingNodeCard.test.tsx
@@ -0,0 +1,129 @@
+/**
+ * Covers the routing node state derivation and the post-enable auto-converge.
+ *
+ * `deriveNodeState` is the function this change reworked to split the transient
+ * `connecting` (proxy bridge mid-dial) from a genuine `degraded` bridge fault.
+ * The auto-converge re-poll keeps the card from stranding on a manual refresh
+ * after enable, and must not fire after a disable or after unmount.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import type { MeshNodeStatus } from '@/types/mesh';
+
+vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
+vi.mock('@/components/ui/toast-store', () => ({
+ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
+}));
+
+import { apiFetch } from '@/lib/api';
+import { RoutingNodeCard } from './RoutingNodeCard';
+import { deriveNodeState } from './routingNodeState';
+
+function node(over: Partial): MeshNodeStatus {
+ return {
+ nodeId: 1,
+ nodeName: 'node-alpha',
+ enabled: true,
+ localForwarderListening: null,
+ pilotConnected: false,
+ reachableMode: 'proxy',
+ reachableReason: null,
+ reverseCallbackStatus: 'connected',
+ optedInStacks: [],
+ activeStreamCount: 0,
+ ...over,
+ };
+}
+
+describe('deriveNodeState', () => {
+ it('is offline when the node is unreachable', () => {
+ expect(deriveNodeState(node({ reachableMode: 'unreachable' }))).toBe('offline');
+ });
+ it('is idle when mesh is disabled', () => {
+ expect(deriveNodeState(node({ enabled: false }))).toBe('idle');
+ });
+ it('is degraded when a pilot tunnel is down', () => {
+ expect(deriveNodeState(node({ reachableMode: 'pilot', pilotConnected: false }))).toBe('degraded');
+ });
+ it('is connecting while the proxy bridge is dialing', () => {
+ expect(deriveNodeState(node({ reverseCallbackStatus: 'connecting' }))).toBe('connecting');
+ });
+ it('is degraded when the proxy bridge is unavailable', () => {
+ expect(deriveNodeState(node({ reverseCallbackStatus: 'unavailable' }))).toBe('degraded');
+ });
+ it('is meshed when the proxy bridge is connected', () => {
+ expect(deriveNodeState(node({ reverseCallbackStatus: 'connected' }))).toBe('meshed');
+ });
+});
+
+function renderCard(status: MeshNodeStatus) {
+ const onChanged = vi.fn();
+ const view = render(
+ {}}
+ onChanged={onChanged}
+ canManage
+ />,
+ );
+ return { onChanged, view };
+}
+
+describe('RoutingNodeCard enable auto-converge', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.mocked(apiFetch).mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as unknown as Response);
+ });
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ });
+
+ it('re-polls a few times after enabling so the card converges without a manual refresh', async () => {
+ const { onChanged } = renderCard(node({ enabled: false, reverseCallbackStatus: 'not_applicable' }));
+ await act(async () => { fireEvent.click(screen.getByRole('switch')); });
+ // Immediate refresh once the enable resolves.
+ expect(onChanged).toHaveBeenCalledTimes(1);
+ await act(async () => { vi.advanceTimersByTime(6000); });
+ // Plus the three scheduled re-polls.
+ expect(onChanged).toHaveBeenCalledTimes(4);
+ });
+
+ it('does not schedule re-polls after disabling', async () => {
+ const { onChanged } = renderCard(node({ enabled: true, reverseCallbackStatus: 'connected' }));
+ await act(async () => { fireEvent.click(screen.getByRole('switch')); });
+ expect(onChanged).toHaveBeenCalledTimes(1);
+ await act(async () => { vi.advanceTimersByTime(6000); });
+ expect(onChanged).toHaveBeenCalledTimes(1);
+ });
+
+ it('clears pending re-poll timers on unmount', async () => {
+ const { onChanged, view } = renderCard(node({ enabled: false, reverseCallbackStatus: 'not_applicable' }));
+ await act(async () => { fireEvent.click(screen.getByRole('switch')); });
+ expect(onChanged).toHaveBeenCalledTimes(1);
+ view.unmount();
+ await act(async () => { vi.advanceTimersByTime(6000); });
+ expect(onChanged).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not fire after the card unmounts before the enable request resolves', async () => {
+ let resolveEnable: (value: unknown) => void = () => {};
+ vi.mocked(apiFetch).mockReturnValueOnce(
+ new Promise((resolve) => { resolveEnable = resolve; }) as unknown as Promise,
+ );
+ const { onChanged, view } = renderCard(node({ enabled: false, reverseCallbackStatus: 'not_applicable' }));
+ await act(async () => { fireEvent.click(screen.getByRole('switch')); });
+ view.unmount();
+ await act(async () => {
+ resolveEnable({ ok: true, status: 200, json: async () => ({}) });
+ await Promise.resolve();
+ vi.advanceTimersByTime(6000);
+ });
+ expect(onChanged).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/fleet/RoutingNodeCard.tsx b/frontend/src/components/fleet/RoutingNodeCard.tsx
index 9e4bdda6..3082cb45 100644
--- a/frontend/src/components/fleet/RoutingNodeCard.tsx
+++ b/frontend/src/components/fleet/RoutingNodeCard.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { formatAgeShort } from '@/lib/relativeTime';
@@ -9,6 +9,7 @@ import {
type RoutingNodeCardMeta,
type RoutingNodeState,
} from '@/components/ui/routing-node-card';
+import { deriveNodeState } from './routingNodeState';
interface Props {
status: MeshNodeStatus;
@@ -28,14 +29,6 @@ const REVERSE_BRIDGE: Record>(() => new Map());
const lastSeenRef = useRef(Date.now());
const lastStatusSignatureRef = useRef('');
+ 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[]>([]);
+ 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.
@@ -113,7 +123,6 @@ export function RoutingNodeCard({
const nodeState = deriveNodeState(status);
const meta: RoutingNodeCardMeta = {
- pilotConnected: status.pilotConnected,
reverseBridge: REVERSE_BRIDGE[status.reverseCallbackStatus],
stacks: status.optedInStacks.length,
aliases: nodeAliases.length,
@@ -123,6 +132,9 @@ export function RoutingNodeCard({
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';
@@ -130,12 +142,23 @@ export function RoutingNodeCard({
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 {
- setToggling(false);
+ if (mountedRef.current) setToggling(false);
}
};
diff --git a/frontend/src/components/fleet/RoutingTab.tsx b/frontend/src/components/fleet/RoutingTab.tsx
index f04a5660..bcf87e1f 100644
--- a/frontend/src/components/fleet/RoutingTab.tsx
+++ b/frontend/src/components/fleet/RoutingTab.tsx
@@ -11,18 +11,11 @@ import { MeshRouteDetailSheet } from './MeshRouteDetailSheet';
import { MeshDiagnosticsSheet } from './MeshDiagnosticsSheet';
import { MeshActivitySheet } from './MeshActivitySheet';
import { MeshTopologyGraph, type MeshGraphEdgeMode } from './MeshTopologyGraph';
-import { MeshStackTopologySheet } from './MeshStackTopologySheet';
import { SegmentedControl } from '@/components/ui/segmented-control';
import type { MeshAlias, MeshDataPlaneStatus, MeshNodeStatus, MeshProbeResult } from '@/types/mesh';
type RoutingViewMode = 'table' | 'graph';
-interface TopologyStackTarget {
- nodeId: number;
- nodeName: string;
- stack: string;
-}
-
const MESH_REFRESH_INTERVAL_MS = 30000;
const VIEW_MODE_KEY = 'sencho-routing-view-mode';
const EDGE_MODE_KEY = 'sencho-routing-edge-mode';
@@ -56,7 +49,6 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
const [activityOpen, setActivityOpen] = useState(false);
const [viewMode, setViewMode] = useState(readStoredViewMode);
const [edgeMode, setEdgeMode] = useState(readStoredEdgeMode);
- const [topologyStack, setTopologyStack] = useState(null);
const refresh = useCallback(async (opts: { silent?: boolean } = {}) => {
try {
@@ -189,8 +181,6 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
activityOpen={activityOpen} setActivityOpen={setActivityOpen}
- topologyStack={topologyStack}
- setTopologyStack={setTopologyStack}
status={status}
aliases={aliases}
onChanged={() => { void refresh(); }}
@@ -255,8 +245,6 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
activityOpen={activityOpen} setActivityOpen={setActivityOpen}
- topologyStack={topologyStack}
- setTopologyStack={setTopologyStack}
status={status}
aliases={aliases}
onChanged={() => { void refresh(); }}
@@ -301,13 +289,15 @@ function SheetsRoot(props: {
setRouteDetailAlias: (v: string | null) => void;
activityOpen: boolean;
setActivityOpen: (v: boolean) => void;
- topologyStack: TopologyStackTarget | null;
- setTopologyStack: (v: TopologyStackTarget | null) => void;
status: MeshNodeStatus[];
aliases: MeshAlias[];
onChanged: () => void;
}) {
const optInNode = props.optInNode;
+ const diagNode = props.diagnosticsNode;
+ const diagNodeStatus = diagNode
+ ? (props.status.find((s) => s.nodeId === diagNode.id) ?? null)
+ : null;
return (
<>
{optInNode && (
@@ -318,10 +308,6 @@ function SheetsRoot(props: {
nodeName={optInNode.name}
canManage={props.canManage}
onChanged={props.onChanged}
- onViewTopology={(stack) => {
- props.setTopologyStack({ nodeId: optInNode.id, nodeName: optInNode.name, stack });
- props.setOptInNode(null);
- }}
/>
)}
{ if (!open) props.setDiagnosticsNode(null); }}
nodeId={props.diagnosticsNode?.id ?? null}
nodeName={props.diagnosticsNode?.name ?? null}
+ nodeStatus={diagNodeStatus}
/>
{ if (!open) props.setRouteDetailAlias(null); }}
alias={props.routeDetailAlias}
+ canManage={props.canManage}
+ status={props.status}
+ aliases={props.aliases}
+ onChanged={props.onChanged}
/>
- { if (!open) props.setTopologyStack(null); }}
- nodeId={props.topologyStack?.nodeId ?? null}
- nodeName={props.topologyStack?.nodeName ?? null}
- stackName={props.topologyStack?.stack ?? null}
- status={props.status}
- aliases={props.aliases}
- />
>
);
}
diff --git a/frontend/src/components/fleet/meshTransport.test.ts b/frontend/src/components/fleet/meshTransport.test.ts
new file mode 100644
index 00000000..30c99b65
--- /dev/null
+++ b/frontend/src/components/fleet/meshTransport.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import { describeTransport, reverseBridgeLabel } from './meshTransport';
+import type { MeshNodeStatus } from '@/types/mesh';
+
+function node(over: Partial): MeshNodeStatus {
+ return {
+ nodeId: 1,
+ nodeName: 'n',
+ enabled: true,
+ localForwarderListening: null,
+ pilotConnected: false,
+ reachableMode: 'local',
+ reachableReason: null,
+ reverseCallbackStatus: 'not_applicable',
+ optedInStacks: [],
+ activeStreamCount: 0,
+ ...over,
+ };
+}
+
+describe('reverseBridgeLabel', () => {
+ it('maps every reverse-callback status', () => {
+ expect(reverseBridgeLabel('connected')).toBe('connected');
+ expect(reverseBridgeLabel('connecting')).toBe('connecting');
+ expect(reverseBridgeLabel('unavailable')).toBe('unavailable');
+ expect(reverseBridgeLabel('not_applicable')).toBe('n/a');
+ });
+});
+
+describe('describeTransport', () => {
+ it('returns an unknown line for a missing node rather than a false positive', () => {
+ expect(describeTransport(undefined, true)).toEqual({ label: 'Transport', value: 'unknown' });
+ });
+
+ it('labels a local node as in-process', () => {
+ expect(describeTransport(node({ reachableMode: 'local' }), false))
+ .toEqual({ label: 'Transport', value: 'local (in-process)' });
+ });
+
+ it('still labels a pilot node with its tunnel state', () => {
+ expect(describeTransport(node({ reachableMode: 'pilot' }), true))
+ .toEqual({ label: 'Pilot tunnel', value: 'connected' });
+ expect(describeTransport(node({ reachableMode: 'pilot' }), false))
+ .toEqual({ label: 'Pilot tunnel', value: 'disconnected' });
+ });
+
+ it('labels a proxy node with its API proxy bridge state, never a pilot tunnel', () => {
+ expect(describeTransport(node({ reachableMode: 'proxy', reverseCallbackStatus: 'connected' }), false))
+ .toEqual({ label: 'API proxy bridge', value: 'connected' });
+ expect(describeTransport(node({ reachableMode: 'proxy', reverseCallbackStatus: 'connecting' }), false).value)
+ .toBe('connecting');
+ expect(describeTransport(node({ reachableMode: 'proxy', reverseCallbackStatus: 'unavailable' }), false).value)
+ .toBe('unavailable');
+ });
+
+ it('labels an unreachable node', () => {
+ expect(describeTransport(node({ reachableMode: 'unreachable' }), false))
+ .toEqual({ label: 'Transport', value: 'unreachable' });
+ });
+});
diff --git a/frontend/src/components/fleet/meshTransport.ts b/frontend/src/components/fleet/meshTransport.ts
new file mode 100644
index 00000000..1680cb9f
--- /dev/null
+++ b/frontend/src/components/fleet/meshTransport.ts
@@ -0,0 +1,44 @@
+import type { MeshNodeStatus, MeshReverseCallbackStatus } from '@/types/mesh';
+
+export function reverseBridgeLabel(status: MeshReverseCallbackStatus): string {
+ switch (status) {
+ case 'connected': return 'connected';
+ case 'connecting': return 'connecting';
+ case 'unavailable': return 'unavailable';
+ case 'not_applicable': return 'n/a';
+ default: {
+ const _exhaustive: never = status;
+ throw new Error(`Unhandled reverse callback status: ${String(_exhaustive)}`);
+ }
+ }
+}
+
+export interface TransportLine {
+ label: string;
+ value: string;
+}
+
+/**
+ * One-line transport descriptor for a mesh node, keyed off how the node
+ * actually participates in routing. "Pilot tunnel" is shown only for
+ * pilot-agent nodes; proxy peers report their API proxy bridge state, and the
+ * local node runs in-process. This keeps the Routing diagnostics honest for a
+ * fleet that connects its remotes over the HTTP API proxy rather than a pilot.
+ */
+export function describeTransport(node: MeshNodeStatus | undefined, pilotConnected: boolean): TransportLine {
+ if (!node) return { label: 'Transport', value: 'unknown' };
+ switch (node.reachableMode) {
+ case 'local':
+ return { label: 'Transport', value: 'local (in-process)' };
+ case 'pilot':
+ return { label: 'Pilot tunnel', value: pilotConnected ? 'connected' : 'disconnected' };
+ case 'proxy':
+ return { label: 'API proxy bridge', value: reverseBridgeLabel(node.reverseCallbackStatus) };
+ case 'unreachable':
+ return { label: 'Transport', value: 'unreachable' };
+ default: {
+ const _exhaustive: never = node.reachableMode;
+ throw new Error(`Unhandled reachable mode: ${String(_exhaustive)}`);
+ }
+ }
+}
diff --git a/frontend/src/components/fleet/routingNodeState.ts b/frontend/src/components/fleet/routingNodeState.ts
new file mode 100644
index 00000000..39be937e
--- /dev/null
+++ b/frontend/src/components/fleet/routingNodeState.ts
@@ -0,0 +1,17 @@
+import type { MeshNodeStatus } from '@/types/mesh';
+import type { RoutingNodeState } from '@/components/ui/routing-node-card';
+
+/**
+ * Classify a node's mesh status into the card's visual state. The notable
+ * distinction: a proxy peer's reverse bridge mid-dial is the expected transient
+ * right after enable (`connecting`), not a fault. Only a bridge that is fully
+ * unavailable (no dial in flight) is `degraded`.
+ */
+export function deriveNodeState(status: MeshNodeStatus): RoutingNodeState {
+ if (status.reachableMode === 'unreachable') return 'offline';
+ if (!status.enabled) return 'idle';
+ if (status.reachableMode === 'pilot' && !status.pilotConnected) return 'degraded';
+ if (status.reverseCallbackStatus === 'connecting') return 'connecting';
+ if (status.reverseCallbackStatus === 'unavailable') return 'degraded';
+ return 'meshed';
+}
diff --git a/frontend/src/components/ui/routing-node-card.test.tsx b/frontend/src/components/ui/routing-node-card.test.tsx
index 86dfbfff..f99feb12 100644
--- a/frontend/src/components/ui/routing-node-card.test.tsx
+++ b/frontend/src/components/ui/routing-node-card.test.tsx
@@ -16,7 +16,7 @@ function renderCard(overrides: Partial = {}) {
crumb: ['Routing', 'Node', 'node-alpha'],
name: 'node-alpha',
nodeState: 'idle',
- meta: { pilotConnected: true, reverseBridge: 'na', stacks: 0, aliases: 0 },
+ meta: { reverseBridge: 'na', stacks: 0, aliases: 0 },
aliases: [],
onToggleEnabled: vi.fn(),
onShowDiagnostics: vi.fn(),
@@ -49,7 +49,7 @@ describe('routing-node-card canManage gate', () => {
it('hides the add-stack CTA for a non-manager on a meshed node', () => {
renderCard({
nodeState: 'meshed',
- meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 },
+ meta: { reverseBridge: 'up', stacks: 0, aliases: 0 },
canManage: false,
});
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
@@ -72,6 +72,36 @@ describe('routing-node-card canManage gate', () => {
});
});
+describe('routing-node-card add-stack reachability and connecting state', () => {
+ const oneAlias = [{ host: 'web.api.node', port: 8080, kind: 'alias' as const }];
+
+ it('keeps an add-stack control for a manager on a meshed node that already has aliases', () => {
+ renderCard({
+ nodeState: 'meshed',
+ meta: { reverseBridge: 'up', stacks: 1, aliases: 1 },
+ aliases: oneAlias,
+ canManage: true,
+ });
+ expect(screen.getByRole('button', { name: /Add stack/i })).toBeInTheDocument();
+ });
+
+ it('hides the add-stack control for a non-manager on a meshed node with aliases', () => {
+ renderCard({
+ nodeState: 'meshed',
+ meta: { reverseBridge: 'up', stacks: 1, aliases: 1 },
+ aliases: oneAlias,
+ canManage: false,
+ });
+ expect(screen.queryByRole('button', { name: /Add stack/i })).not.toBeInTheDocument();
+ });
+
+ it('renders the connecting state as passive with no retry button', () => {
+ renderCard({ nodeState: 'connecting', canManage: true });
+ expect(screen.getByText(/Connecting to the mesh/i)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Retry now/i })).not.toBeInTheDocument();
+ });
+});
+
describe('routing-node-card canManage gate (compact density)', () => {
beforeEach(() => {
window.localStorage.setItem('sencho.appearance.density', 'compact');
@@ -83,7 +113,7 @@ describe('routing-node-card canManage gate (compact density)', () => {
it('hides the toggle for a non-manager on a meshed node', () => {
renderCard({
nodeState: 'meshed',
- meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 },
+ meta: { reverseBridge: 'up', stacks: 0, aliases: 0 },
canManage: false,
});
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
@@ -92,7 +122,7 @@ describe('routing-node-card canManage gate (compact density)', () => {
it('shows the toggle for a manager on a meshed node', () => {
renderCard({
nodeState: 'meshed',
- meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 },
+ meta: { reverseBridge: 'up', stacks: 0, aliases: 0 },
canManage: true,
});
expect(screen.getByRole('switch')).toBeInTheDocument();
diff --git a/frontend/src/components/ui/routing-node-card.tsx b/frontend/src/components/ui/routing-node-card.tsx
index f3227836..3f18f448 100644
--- a/frontend/src/components/ui/routing-node-card.tsx
+++ b/frontend/src/components/ui/routing-node-card.tsx
@@ -7,7 +7,7 @@ import { useDensity } from '@/hooks/use-density';
import { formatAgeShort } from '@/lib/relativeTime';
import { cn } from '@/lib/utils';
-export type RoutingNodeState = 'meshed' | 'idle' | 'degraded' | 'offline';
+export type RoutingNodeState = 'meshed' | 'idle' | 'connecting' | 'degraded' | 'offline';
export interface RoutingAliasRow {
host: string;
@@ -18,7 +18,6 @@ export interface RoutingAliasRow {
}
export interface RoutingNodeCardMeta {
- pilotConnected: boolean;
reverseBridge: 'up' | 'unavailable' | 'na';
stacks: number;
aliases: number;
@@ -56,6 +55,7 @@ const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
const RAIL_CLASS: Record = {
meshed: 'bg-brand',
idle: '',
+ connecting: 'bg-brand animate-pulse',
degraded: 'bg-warning',
offline: 'bg-destructive',
};
@@ -63,6 +63,7 @@ const RAIL_CLASS: Record = {
const RAIL_INLINE_STYLE: Record = {
meshed: undefined,
idle: { background: 'oklch(0.28 0 0)' },
+ connecting: undefined,
degraded: undefined,
offline: undefined,
};
@@ -70,6 +71,7 @@ const RAIL_INLINE_STYLE: Record = {
meshed: { label: 'Meshed', tone: 'border-brand/40 bg-brand/10 text-brand' },
idle: { label: 'Idle', tone: 'border-card-border bg-card text-stat-subtitle' },
+ connecting: { label: 'Connecting', tone: 'border-brand/40 bg-brand/10 text-brand' },
degraded: { label: 'Degraded', tone: 'border-warning/40 bg-warning/10 text-warning' },
offline: { label: 'Offline', tone: 'border-destructive/40 bg-destructive/10 text-destructive' },
};
@@ -92,7 +94,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
const toggleDisabled = nodeState === 'offline';
const diagnosticsDisabled = nodeState === 'offline';
- const isEnabled = nodeState === 'meshed' || nodeState === 'degraded';
+ const isEnabled = nodeState === 'meshed' || nodeState === 'degraded' || nodeState === 'connecting';
const chip = STATE_CHIP[nodeState];
const railClass = RAIL_CLASS[nodeState];
@@ -119,7 +121,6 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
onShowDiagnostics={onShowDiagnostics}
canManage={canManage}
footerContext={footerContext}
- aliasesEmpty={aliases.length === 0}
onAddStack={onAddStack}
onRetry={onRetry}
/>
@@ -165,10 +166,6 @@ interface BodyChrome {
onRetry?: () => void;
}
-interface CompactProps extends BodyChrome {
- aliasesEmpty: boolean;
-}
-
interface ComfortableProps extends BodyChrome {
crumb: string[];
aliases: RoutingAliasRow[];
@@ -207,8 +204,7 @@ function ComfortableBody(props: ComfortableProps) {
)}