Add `currentlyResolvable: boolean` per entry in `MeshNodeStatus.optedInStacks`,
derived from the existing alias cache so the new field stays consistent with
`/api/mesh/aliases` without any extra Dockerode or cross-node inspect on the
status path. The Routing tab renders an amber `suspended` pill for entries
whose stack is opted in but currently has no running services, plus a single
explanatory caption below the suspended list.
Resolves the contradictory `Mesh stacks: 1 / Aliases: 0 / No mesh services
on this node yet` copy on the node card when a meshed stack's container has
been stopped; the misleading line is now only shown when the node truly has
no opt-ins. The opt-in itself remains sticky: when the stack starts again,
its aliases reappear automatically on the next refresh.
Defensive de-dup in the UI filters suspended entries against the live alias
snapshot to handle the transient gap where `/mesh/status` and `/mesh/aliases`
return slightly inconsistent views from their separate fetches.
Tests:
- new `mesh-status-resolvability.test.ts` (6 cases) locks the resolvable /
suspended / mixed / empty / per-node-scoping / stale-alias-no-phantom
invariants for `getStatus`.
- `mesh-topology-layout.test.ts` gains a `stacksKey` resolvability-flip case
and a `meshNodeStateEqual` case asserting a resolvability flip on an
otherwise identical stack registers as a state change so the topology
layout re-runs.
Operator docs gain one new troubleshooting accordion in
`/docs/features/sencho-mesh.mdx` explaining the suspended state.
* fix(mesh): route peer→central traffic over the existing forward WS
The reverse mesh callback path (`/api/mesh/proxy-tunnel-from-peer`) needed
SENCHO_PRIMARY_URL on central plus a publicly reachable origin from the
peer's perspective. In a typical homelab where central sits behind NAT,
peer→central dispatch silently failed at the dialer's short-circuit and
the headline "call any service on any node by hostname" worked one way
only.
The forward WS at `/api/mesh/proxy-tunnel` is already bidirectional end
to end. Make the bridge a persistent control-plane primitive: dial every
mesh-enabled proxy peer at startup, reconcile every 60 s, never idle-close.
Peer→central traffic multiplexes over the same WS via `tcp_open_reverse`.
Removed:
- `meshProxyTunnelFromPeer.ts` WS handler and dispatch
- `MeshCentralRegistry`, `PeerToCentralMeshSessionDialer`
- `mesh_handshake` first-frame state machine in `meshProxyTunnel.ts`
- `maybeSendBootstrap`, `buildHandshakeFrame` in the dialer
- `mesh_proxy_callback_bootstrap` capability and `maybeWarnUnsetPrimaryUrl`
- `mesh_centrals` table (drop migration; greenfield, no users)
- `PilotTunnelManager.replaceOrRegisterProxyBridge` (dead after handler removal)
- twelve associated unit/integration tests plus the peer-recovery branch
in `MeshService.openCrossNode`
Added:
- `MeshService.proactiveBridgeFanout` selects every mesh-enabled proxy
peer (no longer gated on `mesh_stacks` rows)
- `startBridgeReconcileLoop` runs the fanout every 60 s (override via
`SENCHO_MESH_RECONCILE_INTERVAL_MS`)
- `MeshProxyTunnelDialer` default idle TTL is now `0` and exposes
`isDialing(nodeId)` for the status surface
- `MeshNodeStatus.reverseCallbackStatus` discriminator
(`connected | connecting | unavailable | not_applicable`) surfaced via
`/api/mesh/status` and rendered as a pill in the Routing tab
- `openCrossNode` error message distinguishes "no proxy target" from
"waiting for central to dial the reverse bridge"
- New tests: `mesh-service-proxy-tunnel-reconcile`,
`mesh-status-reverse-callback`, `mesh-proxy-tunnel-dialer-no-idle-close`
SENCHO_PRIMARY_URL is no longer required for any mesh function.
* fix(mesh): rewrite proxy-tunnel reconcile test contents
The previous commit renamed the file but the rewritten test bodies stayed
unstaged on top of the rename. This commit lands the actual rewrite: the
fanout assertion now requires every mesh-enabled proxy peer to be dialed,
not just those with `mesh_stacks` rows, and adds a reconcile-tick
repeated-call test.
Adds a visibility-aware 30s poll on the Routing tab so the graph reflects
tunnel state, alias publishes, and remote-node disconnects without a
manual reload. Polling pauses while the tab is hidden and wakes
immediately on focus via the shared visibilityInterval helper.
Tightens nodeStateEqual so a stack swap on the same node (opt out A, opt
in B) is detected even when the count is unchanged. Extracts the helper,
the minimap colour mapping, and the minimap colour literals to
mesh-topology-layout so they can be unit-tested and to drop a now-unused
isOwnerView field from MeshNodeData.
Persists the Table/Graph and Tunnels/Aliases toggles to localStorage so a
Routing tab session keeps the operator's last-used view.
Adds a legend line to the per-stack topology sheet clarifying that
consumer edges mean meshed peers that could reach the aliases via DNS;
whether containers dial them depends on each consumer's own opt-in
stacks.
Adds unit tests for stacksKey, meshNodeStateEqual, and miniMapColorFor,
plus troubleshooting entries and a refresh-cadence note to the mesh docs.
Adds a Table/Graph toggle to Fleet → Traffic. Graph mode draws the fleet
as a ReactFlow diagram with a second toggle that switches edge encoding
between Tunnels (one edge per node pair, coloured by tunnel state) and
Aliases (same edges labelled with alias counts). Clicking a node card
opens the existing opt-in sheet, where each opted-in stack now has a
Topology button that opens a focused side sheet showing the stack at
the centre, the aliases it publishes, and the meshed consumer nodes
with per-tunnel state.
Reuses the existing /mesh/status and /mesh/aliases endpoints; no
backend changes. Inherits the Admiral gate from the parent Routing
tab. Layout helpers and the per-stack sheet are unit-tested.