Files
sencho/frontend/src/components/fleet/MeshRouteDetailSheet.test.tsx
T
Anso 81858a0bb0 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.
2026-06-04 21:45:53 -04:00

145 lines
6.2 KiB
TypeScript

/**
* 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();
});
});