mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +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:
@@ -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