mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
feat(mesh): refine the Routing tab and add per-route removal (#1311)
* 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.
This commit is contained in:
@@ -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.
|
||||
|
||||
<Frame caption="The opt-in sheet. Already-meshed stacks show an `in mesh` pill plus a Topology shortcut; the rest get an Add to mesh button.">
|
||||
<Frame caption="The opt-in sheet. Already-meshed stacks show an `in mesh` pill; the rest get an Add to mesh button. The action stays reachable from each node card even after the first stack is added.">
|
||||
<img src="/images/sencho-mesh/opt-in-sheet.png" alt="Mesh opt-in sheet listing stacks on a node" />
|
||||
</Frame>
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Frame caption="Per-stack topology. Stack at the centre, every alias it publishes to the right, and a column of meshed consumer nodes with their tunnel state.">
|
||||
<img src="/images/sencho-mesh/stack-topology-sheet.png" alt="Per-stack mesh topology sheet" />
|
||||
@@ -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.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="A node card shows `pilot offline`">
|
||||
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.
|
||||
<Accordion title="A node card shows `Degraded`">
|
||||
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.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="A node card shows `unreachable`">
|
||||
|
||||
@@ -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<MeshNodeDiagnostic | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(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"
|
||||
>
|
||||
<SheetSection title="Forwarder · pilot · transport">
|
||||
<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">Pilot tunnel</div>
|
||||
<div className="font-mono text-stat-value">{diag?.pilot.connected ? 'connected' : 'disconnected'}</div>
|
||||
<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 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.
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
* Opting a stack in or out is admin-only on the backend
|
||||
* (POST /api/mesh/nodes/:id/stacks/:stack/opt-in|opt-out require admin). This
|
||||
* test locks the matching UI gate: a manager sees Add/Remove buttons, a
|
||||
* non-manager sees the membership read-only with a hint and still gets the
|
||||
* read-only topology affordance. The read-only branch must never issue the
|
||||
* admin-only mutation.
|
||||
* non-manager sees the membership read-only with a hint. The read-only branch
|
||||
* must never issue the admin-only mutation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
@@ -41,7 +40,6 @@ function renderSheet(canManage: boolean) {
|
||||
nodeId={1}
|
||||
nodeName="node-alpha"
|
||||
onChanged={() => {}}
|
||||
onViewTopology={() => {}}
|
||||
canManage={canManage}
|
||||
/>,
|
||||
);
|
||||
@@ -62,8 +60,6 @@ describe('MeshOptInSheet canManage gate', () => {
|
||||
expect(screen.queryByRole('button', { name: /Remove from mesh/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Add to mesh/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Changing mesh membership requires an administrator/i)).toBeInTheDocument();
|
||||
// Topology is read-only and stays available for opted-in stacks.
|
||||
expect(screen.getByRole('button', { name: /View topology for web/i })).toBeInTheDocument();
|
||||
// The read-only branch must never issue an opt-in/opt-out request.
|
||||
expect(vi.mocked(apiFetch)).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('/opt-'),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SystemSheet } from '@/components/ui/system-sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import type { MeshStackEntry } from '@/types/mesh';
|
||||
import { Loader2, Workflow } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -13,12 +13,11 @@ interface Props {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
onChanged: () => void;
|
||||
onViewTopology?: (stack: string) => void;
|
||||
/** Opt-in/out is admin-only on the backend; non-admins see the list read-only. */
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged, onViewTopology, canManage }: Props) {
|
||||
export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged, canManage }: Props) {
|
||||
const [stacks, setStacks] = useState<MeshStackEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -125,16 +124,6 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
|
||||
<Loader2 className="w-4 h-4 animate-spin text-stat-subtitle" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{stack.optedIn && onViewTopology && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onViewTopology(stack.name)}
|
||||
aria-label={`View topology for ${stack.name}`}
|
||||
>
|
||||
<Workflow className="w-3 h-3 mr-1" /> Topology
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Render-gate coverage for the alias-detail Remove control and transport line.
|
||||
*
|
||||
* Removing a route opts its owning stack out of the mesh, an admin-only mutation
|
||||
* (POST /api/mesh/nodes/:id/stacks/:stack/opt-out requires admin). This locks the
|
||||
* matching UI gate: a manager sees "Remove from mesh", a non-manager does not,
|
||||
* while the read-only route detail stays available to both. It also pins the
|
||||
* transport line to the node's actual transport so a proxy peer never reports a
|
||||
* "Pilot tunnel".
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import type { MeshNodeStatus, MeshRouteDiagnostic } 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() },
|
||||
}));
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
ReactFlow: () => null,
|
||||
Background: () => null,
|
||||
Handle: () => null,
|
||||
Position: { Left: 'left', Right: 'right' },
|
||||
useNodesState: <T,>() => [[] as T[], () => {}, () => {}],
|
||||
useEdgesState: <T,>() => [[] as T[], () => {}, () => {}],
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { MeshRouteDetailSheet } from './MeshRouteDetailSheet';
|
||||
|
||||
const ALIAS = 'web.api.peer.sencho';
|
||||
|
||||
const DIAG: MeshRouteDiagnostic = {
|
||||
alias: ALIAS,
|
||||
target: { nodeId: 2, stack: 'api', service: 'web', port: 80, alias: ALIAS },
|
||||
pilot: { connected: false, lastSeen: null },
|
||||
lastError: null,
|
||||
lastProbeMs: null,
|
||||
lastProbeAt: null,
|
||||
state: 'healthy',
|
||||
};
|
||||
|
||||
const STATUS: MeshNodeStatus[] = [
|
||||
{
|
||||
nodeId: 2, nodeName: 'peer-a', enabled: true, localForwarderListening: null,
|
||||
pilotConnected: false, reachableMode: 'proxy', reachableReason: null,
|
||||
reverseCallbackStatus: 'connected', optedInStacks: [], activeStreamCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// One combined payload serves both the diagnostic and activity fetches:
|
||||
// the diagnostic reader uses the route fields, the activity reader reads `events`.
|
||||
const combined = { ...DIAG, events: [] };
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => combined,
|
||||
} as unknown as Response);
|
||||
});
|
||||
|
||||
function renderSheet(canManage: boolean, onChanged: () => void = () => {}) {
|
||||
return render(
|
||||
<MeshRouteDetailSheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
alias={ALIAS}
|
||||
canManage={canManage}
|
||||
status={STATUS}
|
||||
aliases={[]}
|
||||
onChanged={onChanged}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MeshRouteDetailSheet remove gate', () => {
|
||||
it('shows Remove from mesh for a manager once the target resolves', async () => {
|
||||
renderSheet(true);
|
||||
expect(await screen.findByRole('button', { name: /Remove from mesh/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Remove from mesh for a non-manager', async () => {
|
||||
renderSheet(false);
|
||||
// Wait for the diagnostic to load, then assert the remove control is absent.
|
||||
expect(await screen.findByText('Target node')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Remove from mesh/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels a proxy peer transport as the API proxy bridge, not a pilot tunnel', async () => {
|
||||
renderSheet(true);
|
||||
expect(await screen.findByText('API proxy bridge')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pilot tunnel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opts the owning stack out via the opt-out endpoint when the removal is confirmed', async () => {
|
||||
const onChanged = vi.fn();
|
||||
renderSheet(true, onChanged);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Remove from mesh/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Remove and redeploy/i }));
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(apiFetch)).toHaveBeenCalledWith(
|
||||
'/mesh/nodes/2/stacks/api/opt-out',
|
||||
{ method: 'POST', localOnly: true },
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('ignores a superseded alias diagnostic whose body resolves after switching aliases', async () => {
|
||||
let resolveAJson: (value: unknown) => void = () => {};
|
||||
const diagA: MeshRouteDiagnostic = {
|
||||
...DIAG,
|
||||
alias: 'aliasA',
|
||||
target: { nodeId: 9, stack: 'old-stack', service: 'web', port: 1, alias: 'aliasA' },
|
||||
};
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
if (url.includes('aliasA') && url.includes('/diagnostic')) {
|
||||
return Promise.resolve({ ok: true, json: () => new Promise((r) => { resolveAJson = r; }) } as unknown as Response);
|
||||
}
|
||||
if (url.includes('aliasB') && url.includes('/diagnostic')) {
|
||||
return Promise.resolve({ ok: true, json: async () => DIAG } as unknown as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({ events: [] }) } as unknown as Response);
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<MeshRouteDetailSheet open onOpenChange={() => {}} alias="aliasA" canManage status={STATUS} aliases={[]} onChanged={() => {}} />,
|
||||
);
|
||||
// Let alias A's fetches resolve so its effect parks on the deferred body read.
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
|
||||
rerender(
|
||||
<MeshRouteDetailSheet open onOpenChange={() => {}} alias="aliasB" canManage status={STATUS} aliases={[]} onChanged={() => {}} />,
|
||||
);
|
||||
expect(await screen.findByText('API proxy bridge')).toBeInTheDocument();
|
||||
|
||||
// Resolve the superseded alias A body; it must not overwrite alias B.
|
||||
await act(async () => { resolveAJson(diagA); await new Promise((r) => setTimeout(r, 0)); });
|
||||
expect(screen.queryByText(/old-stack/)).not.toBeInTheDocument();
|
||||
// Alias B's proxy transport label survives; alias A (node 9, not in status)
|
||||
// would have flipped it to an unknown transport had it overwritten.
|
||||
expect(screen.getByText('API proxy bridge')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,46 @@
|
||||
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 { Loader2, Activity, Hash } from 'lucide-react';
|
||||
import type { MeshRouteDiagnostic, MeshActivityEvent, MeshProbeResult } from '@/types/mesh';
|
||||
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' | 'raw';
|
||||
type RouteTab = 'overview' | 'events' | 'topology' | 'raw';
|
||||
|
||||
export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) {
|
||||
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);
|
||||
@@ -33,12 +49,15 @@ export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) {
|
||||
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 (diagRes.ok) setDiag(await diagRes.json());
|
||||
if (evRes.ok) {
|
||||
const body = await evRes.json() as { events: MeshActivityEvent[] };
|
||||
setEvents(body.events);
|
||||
}
|
||||
if (diagBody) setDiag(diagBody);
|
||||
if (evBody) setEvents(evBody.events);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
@@ -62,6 +81,26 @@ export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -78,7 +117,15 @@ export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) {
|
||||
: `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}
|
||||
@@ -91,9 +138,16 @@ export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) {
|
||||
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}
|
||||
@@ -125,8 +179,8 @@ export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) {
|
||||
<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">Pilot tunnel</div>
|
||||
<div className="font-mono text-stat-value">{diag.pilot.connected ? 'connected' : 'disconnected'}</div>
|
||||
<div className="text-stat-subtitle">{transport.label}</div>
|
||||
<div className="font-mono text-stat-value">{transport.value}</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
)}
|
||||
@@ -162,6 +216,21 @@ export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) {
|
||||
</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">
|
||||
@@ -170,5 +239,21 @@ export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) {
|
||||
</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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-51
@@ -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: () => <div data-testid="reactflow" />,
|
||||
Background: () => null,
|
||||
Handle: () => null,
|
||||
Position: { Left: 'left', Right: 'right' },
|
||||
@@ -40,67 +40,25 @@ function makeAlias(over: Partial<MeshAlias> & Pick<MeshAlias, 'host' | 'nodeId'>
|
||||
|
||||
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(
|
||||
<MeshStackTopologySheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
nodeId={1}
|
||||
nodeName="local"
|
||||
stackName="api"
|
||||
status={baseStatus}
|
||||
aliases={[]}
|
||||
/>,
|
||||
);
|
||||
render(<MeshStackTopologyView nodeId={1} stackName="api" status={baseStatus} aliases={[]} />);
|
||||
|
||||
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(
|
||||
<MeshStackTopologySheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
nodeId={1}
|
||||
nodeName="local"
|
||||
stackName="api"
|
||||
status={baseStatus}
|
||||
aliases={aliases}
|
||||
/>,
|
||||
);
|
||||
render(<MeshStackTopologyView nodeId={1} stackName="api" status={baseStatus} aliases={aliases} />);
|
||||
|
||||
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(
|
||||
<MeshStackTopologySheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
nodeId={1}
|
||||
nodeName="local"
|
||||
stackName="api"
|
||||
status={status}
|
||||
aliases={aliases}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/1 alias · 1 consumer/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+43
-68
@@ -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<Node>([]);
|
||||
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
@@ -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 (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Mesh', ownerName, 'topology']}
|
||||
name={crumbName}
|
||||
meta={meta}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-stat-subtitle leading-snug">
|
||||
Aliases this stack publishes and the meshed nodes that can reach them. Edge styling
|
||||
reflects each consumer's tunnel state.
|
||||
</p>
|
||||
<p className="text-xs text-stat-subtitle leading-snug">
|
||||
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.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-stat-subtitle leading-snug">
|
||||
Aliases this stack publishes and the meshed nodes that can reach them. Edge styling
|
||||
reflects each consumer's tunnel state.
|
||||
</p>
|
||||
<p className="text-xs text-stat-subtitle leading-snug">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{stackAliasCount === 0 ? (
|
||||
<div className="rounded border border-dashed border-card-border bg-card/50 p-8 text-center">
|
||||
<Boxes className="w-10 h-10 text-stat-subtitle mx-auto mb-3" strokeWidth={1.5} />
|
||||
<div className="text-sm font-display italic mb-1">No published mesh services</div>
|
||||
<div className="text-xs text-stat-subtitle">
|
||||
This stack is in the mesh but exposes no service ports for other meshed stacks to reach.
|
||||
</div>
|
||||
{stackAliasCount === 0 ? (
|
||||
<div className="rounded border border-dashed border-card-border bg-card/50 p-8 text-center">
|
||||
<Boxes className="w-10 h-10 text-stat-subtitle mx-auto mb-3" strokeWidth={1.5} />
|
||||
<div className="text-sm font-display italic mb-1">No published mesh services</div>
|
||||
<div className="text-xs text-stat-subtitle">
|
||||
This stack is in the mesh but exposes no service ports for other meshed stacks to reach.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="h-[420px] w-full">
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, minZoom: 0.4 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodesConnectable={false}
|
||||
className="bg-background"
|
||||
>
|
||||
<Background gap={20} size={1} className="opacity-30" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="h-[420px] w-full">
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, minZoom: 0.4 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodesConnectable={false}
|
||||
className="bg-background"
|
||||
>
|
||||
<Background gap={20} size={1} className="opacity-30" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SystemSheet>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>): 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(
|
||||
<RoutingNodeCard
|
||||
status={status}
|
||||
aliases={[]}
|
||||
onAddStack={vi.fn()}
|
||||
onShowDiagnostics={vi.fn()}
|
||||
onShowAlias={vi.fn()}
|
||||
onTestUpstream={async () => {}}
|
||||
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<Response>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<MeshNodeStatus['reverseCallbackStatus'], RoutingNod
|
||||
not_applicable: 'na',
|
||||
};
|
||||
|
||||
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 === 'unavailable' || status.reverseCallbackStatus === 'connecting') return 'degraded';
|
||||
return 'meshed';
|
||||
}
|
||||
|
||||
function buildFooterContext(
|
||||
nodeState: RoutingNodeState,
|
||||
status: MeshNodeStatus,
|
||||
@@ -53,12 +46,16 @@ function buildFooterContext(
|
||||
}
|
||||
case 'idle':
|
||||
return `Mesh off · seen ${seen}`;
|
||||
case 'connecting':
|
||||
return `Bringing up bridge · ${seen}`;
|
||||
case 'degraded':
|
||||
return status.reverseCallbackStatus === 'connecting'
|
||||
? `Redialing · last update ${seen}`
|
||||
: `Last seen ${seen}`;
|
||||
return `Last seen ${seen}`;
|
||||
case 'offline':
|
||||
return `Last seen ${seen}`;
|
||||
default: {
|
||||
const _exhaustive: never = nodeState;
|
||||
throw new Error(`Unhandled routing node state: ${String(_exhaustive)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +67,19 @@ export function RoutingNodeCard({
|
||||
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.
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<RoutingViewMode>(readStoredViewMode);
|
||||
const [edgeMode, setEdgeMode] = useState<MeshGraphEdgeMode>(readStoredEdgeMode);
|
||||
const [topologyStack, setTopologyStack] = useState<TopologyStackTarget | null>(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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MeshDiagnosticsSheet
|
||||
@@ -329,25 +315,21 @@ function SheetsRoot(props: {
|
||||
onOpenChange={(open) => { if (!open) props.setDiagnosticsNode(null); }}
|
||||
nodeId={props.diagnosticsNode?.id ?? null}
|
||||
nodeName={props.diagnosticsNode?.name ?? null}
|
||||
nodeStatus={diagNodeStatus}
|
||||
/>
|
||||
<MeshRouteDetailSheet
|
||||
open={!!props.routeDetailAlias}
|
||||
onOpenChange={(open) => { if (!open) props.setRouteDetailAlias(null); }}
|
||||
alias={props.routeDetailAlias}
|
||||
canManage={props.canManage}
|
||||
status={props.status}
|
||||
aliases={props.aliases}
|
||||
onChanged={props.onChanged}
|
||||
/>
|
||||
<MeshActivitySheet
|
||||
open={props.activityOpen}
|
||||
onOpenChange={props.setActivityOpen}
|
||||
/>
|
||||
<MeshStackTopologySheet
|
||||
open={!!props.topologyStack}
|
||||
onOpenChange={(open) => { 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>): 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' });
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -16,7 +16,7 @@ function renderCard(overrides: Partial<RoutingNodeCardProps> = {}) {
|
||||
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();
|
||||
|
||||
@@ -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<RoutingNodeState, string> = {
|
||||
meshed: 'bg-brand',
|
||||
idle: '',
|
||||
connecting: 'bg-brand animate-pulse',
|
||||
degraded: 'bg-warning',
|
||||
offline: 'bg-destructive',
|
||||
};
|
||||
@@ -63,6 +63,7 @@ const RAIL_CLASS: Record<RoutingNodeState, string> = {
|
||||
const RAIL_INLINE_STYLE: Record<RoutingNodeState, React.CSSProperties | undefined> = {
|
||||
meshed: undefined,
|
||||
idle: { background: 'oklch(0.28 0 0)' },
|
||||
connecting: undefined,
|
||||
degraded: undefined,
|
||||
offline: undefined,
|
||||
};
|
||||
@@ -70,6 +71,7 @@ const RAIL_INLINE_STYLE: Record<RoutingNodeState, React.CSSProperties | undefine
|
||||
const STATE_CHIP: Record<RoutingNodeState, { label: string; tone: string }> = {
|
||||
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) {
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(KICKER, 'mt-1.5 text-stat-subtitle leading-none tracking-[0.14em]')}>
|
||||
pilot {meta.pilotConnected ? 'connected' : 'offline'}
|
||||
{' · '}reverse {REVERSE_LABEL[meta.reverseBridge]}
|
||||
reverse {REVERSE_LABEL[meta.reverseBridge]}
|
||||
{' · '}{meta.stacks} stacks
|
||||
{' · '}{meta.aliases} aliases
|
||||
</div>
|
||||
@@ -231,6 +227,8 @@ function ComfortableBody(props: ComfortableProps) {
|
||||
aliases={aliases}
|
||||
onShowAlias={onShowAlias}
|
||||
onTestAlias={onTestAlias}
|
||||
onAddStack={onAddStack}
|
||||
canManage={canManage}
|
||||
/>
|
||||
: <EmptyState
|
||||
nodeState={nodeState}
|
||||
@@ -248,11 +246,11 @@ function ComfortableBody(props: ComfortableProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function CompactBody(props: CompactProps) {
|
||||
function CompactBody(props: BodyChrome) {
|
||||
const {
|
||||
name, isLocal, chip, meta, nodeState, isEnabled,
|
||||
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
|
||||
footerContext, aliasesEmpty, onAddStack, onRetry, canManage,
|
||||
footerContext, onAddStack, onRetry, canManage,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -290,7 +288,7 @@ function CompactBody(props: CompactProps) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-4 divide-x divide-card-border/40 border-y border-card-border/40 bg-card/60 pl-[3px]">
|
||||
<div className="grid grid-cols-3 divide-x divide-card-border/40 border-y border-card-border/40 bg-card/60 pl-[3px]">
|
||||
<CompactCell
|
||||
dot={meta.stacks > 0 ? 'success' : 'muted'}
|
||||
value={String(meta.stacks)}
|
||||
@@ -301,11 +299,6 @@ function CompactBody(props: CompactProps) {
|
||||
value={String(meta.aliases)}
|
||||
sub="published"
|
||||
/>
|
||||
<CompactCell
|
||||
dot={meta.pilotConnected ? 'success' : 'warning'}
|
||||
value={meta.pilotConnected ? 'on' : 'off'}
|
||||
sub="agent"
|
||||
/>
|
||||
<CompactCell
|
||||
dot={reverseDot(meta.reverseBridge)}
|
||||
value={REVERSE_LABEL[meta.reverseBridge]}
|
||||
@@ -317,7 +310,6 @@ function CompactBody(props: CompactProps) {
|
||||
context={footerContext}
|
||||
nodeState={nodeState}
|
||||
name={name}
|
||||
aliasesEmpty={aliasesEmpty}
|
||||
onAddStack={onAddStack}
|
||||
onRetry={onRetry}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
@@ -388,14 +380,28 @@ interface AliasListProps {
|
||||
aliases: RoutingAliasRow[];
|
||||
onShowAlias?: (alias: string) => void;
|
||||
onTestAlias?: (alias: string) => void;
|
||||
onAddStack?: () => void;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
function AliasList({ title, aliases, onShowAlias, onTestAlias }: AliasListProps) {
|
||||
function AliasList({ title, aliases, onShowAlias, onTestAlias, onAddStack, canManage }: AliasListProps) {
|
||||
return (
|
||||
<section>
|
||||
<h4 className={cn(KICKER, 'text-stat-subtitle leading-none mb-2 tracking-[0.22em]')}>
|
||||
{title}
|
||||
</h4>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 className={cn(KICKER, 'text-stat-subtitle leading-none tracking-[0.22em]')}>
|
||||
{title}
|
||||
</h4>
|
||||
{canManage && onAddStack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAddStack}
|
||||
className="h-6 -my-1 px-1.5 text-stat-subtitle hover:text-brand"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" /> Add stack
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{aliases.map((row) => (
|
||||
<AliasRow
|
||||
@@ -480,6 +486,9 @@ function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onTog
|
||||
// the backend gates on the admin role, so a non-admin viewer sees a hint
|
||||
// instead. The degraded/offline retry is a read-only refresh and stays.
|
||||
const isManagementState = nodeState === 'idle' || nodeState === 'meshed';
|
||||
// `connecting` is transient while the mesh bridge dials; show the headline
|
||||
// only, with no retry button (it clears on its own once the bridge is up).
|
||||
const isConnecting = nodeState === 'connecting';
|
||||
|
||||
const handleClick = () => {
|
||||
if (nodeState === 'idle') onToggleEnabled(true);
|
||||
@@ -487,6 +496,25 @@ function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onTog
|
||||
else onRetry?.();
|
||||
};
|
||||
|
||||
let action: React.ReactNode = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
className={cn('mt-1', ctaToneFor(nodeState))}
|
||||
>
|
||||
{ctaIconFor(nodeState)}{cta}
|
||||
</Button>
|
||||
);
|
||||
if (isConnecting) action = null;
|
||||
else if (!canManage && isManagementState) {
|
||||
action = (
|
||||
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
|
||||
Managing the mesh requires an administrator.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 py-3">
|
||||
<div className="font-display italic text-[18px] leading-[24px] text-stat-value">
|
||||
@@ -495,20 +523,7 @@ function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onTog
|
||||
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
|
||||
{sub}
|
||||
</div>
|
||||
{!canManage && isManagementState ? (
|
||||
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
|
||||
Managing the mesh requires an administrator.
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
className={cn('mt-1', ctaToneFor(nodeState))}
|
||||
>
|
||||
{ctaIconFor(nodeState)}{cta}
|
||||
</Button>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -522,6 +537,7 @@ function ctaIconFor(state: RoutingNodeState): React.ReactNode {
|
||||
const CTA_TONE: Record<RoutingNodeState, string> = {
|
||||
meshed: '',
|
||||
idle: '',
|
||||
connecting: '',
|
||||
degraded: 'border-warning/40 text-warning hover:bg-warning/10',
|
||||
offline: 'border-destructive/40 text-destructive hover:bg-destructive/10',
|
||||
};
|
||||
@@ -530,6 +546,16 @@ function ctaToneFor(state: RoutingNodeState): string {
|
||||
return CTA_TONE[state];
|
||||
}
|
||||
|
||||
// Management CTAs (enable mesh / add stack) need admin; the read-only retry on a
|
||||
// degraded/offline node stays available to everyone. `meshed` always offers "add
|
||||
// stack" to admins so a node with aliases is never a dead end, and the transient
|
||||
// `connecting` state shows no CTA.
|
||||
function shouldShowCta(state: RoutingNodeState, canManage: boolean): boolean {
|
||||
if (state === 'connecting') return false;
|
||||
if (state === 'idle' || state === 'meshed') return canManage;
|
||||
return true;
|
||||
}
|
||||
|
||||
function emptyStateCopy(state: RoutingNodeState, name: string, offlineReason?: string | null): {
|
||||
headline: string; sub: string; cta: string;
|
||||
} {
|
||||
@@ -546,10 +572,16 @@ function emptyStateCopy(state: RoutingNodeState, name: string, offlineReason?: s
|
||||
sub: 'Mesh is on. Opt a stack in to start publishing aliases.',
|
||||
cta: 'Add stack to mesh',
|
||||
};
|
||||
case 'connecting':
|
||||
return {
|
||||
headline: 'Connecting to the mesh…',
|
||||
sub: 'Bringing up the bridge to this node.',
|
||||
cta: '',
|
||||
};
|
||||
case 'degraded':
|
||||
return {
|
||||
headline: 'Pilot tunnel disconnected.',
|
||||
sub: 'Mesh traffic resumes when the agent reconnects.',
|
||||
headline: 'Mesh bridge unavailable.',
|
||||
sub: 'Central retries the connection automatically.',
|
||||
cta: 'Retry now',
|
||||
};
|
||||
case 'offline':
|
||||
@@ -558,6 +590,10 @@ function emptyStateCopy(state: RoutingNodeState, name: string, offlineReason?: s
|
||||
sub: offlineReason || 'Connection refused.',
|
||||
cta: 'Retry now',
|
||||
};
|
||||
default: {
|
||||
const _exhaustive: never = state;
|
||||
throw new Error(`Unhandled routing node state: ${String(_exhaustive)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,18 +611,14 @@ interface CompactFooterProps {
|
||||
context: string;
|
||||
nodeState: RoutingNodeState;
|
||||
name: string;
|
||||
aliasesEmpty: boolean;
|
||||
onAddStack?: () => void;
|
||||
onRetry?: () => void;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
function CompactFooter({ context, nodeState, name, aliasesEmpty, onAddStack, onRetry, onToggleEnabled, canManage }: CompactFooterProps) {
|
||||
// Hide the management CTAs (enable mesh / add stack) for non-admins; the
|
||||
// read-only retry on a degraded/offline node stays available.
|
||||
const isManagementState = nodeState === 'idle' || nodeState === 'meshed';
|
||||
const showCta = (nodeState !== 'meshed' || aliasesEmpty) && (canManage || !isManagementState);
|
||||
function CompactFooter({ context, nodeState, name, onAddStack, onRetry, onToggleEnabled, canManage }: CompactFooterProps) {
|
||||
const showCta = shouldShowCta(nodeState, canManage);
|
||||
const { cta } = emptyStateCopy(nodeState, name);
|
||||
const handleClick = () => {
|
||||
if (nodeState === 'idle') onToggleEnabled(true);
|
||||
|
||||
Reference in New Issue
Block a user