Files
sencho/frontend/src/components/fleet/MeshStackTopologyView.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

65 lines
2.7 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MeshStackTopologyView } from './MeshStackTopologyView';
import type { MeshAlias, MeshNodeStatus } from '@/types/mesh';
vi.mock('@xyflow/react', () => ({
ReactFlow: () => <div data-testid="reactflow" />,
Background: () => null,
Handle: () => null,
Position: { Left: 'left', Right: 'right' },
useNodesState: <T,>() => [[] as T[], () => {}, () => {}],
useEdgesState: <T,>() => [[] as T[], () => {}, () => {}],
}));
function makeNode(over: Partial<MeshNodeStatus> & Pick<MeshNodeStatus, 'nodeId' | 'nodeName'>): MeshNodeStatus {
return {
nodeId: over.nodeId,
nodeName: over.nodeName,
enabled: over.enabled ?? false,
localForwarderListening: over.localForwarderListening ?? null,
pilotConnected: over.pilotConnected ?? false,
reachableMode: over.reachableMode ?? 'unreachable',
reachableReason: over.reachableReason ?? null,
reverseCallbackStatus: over.reverseCallbackStatus ?? 'not_applicable',
optedInStacks: over.optedInStacks ?? [],
activeStreamCount: over.activeStreamCount ?? 0,
};
}
function makeAlias(over: Partial<MeshAlias> & Pick<MeshAlias, 'host' | 'nodeId'>): MeshAlias {
return {
host: over.host,
nodeId: over.nodeId,
nodeName: over.nodeName ?? `node-${over.nodeId}`,
stackName: over.stackName ?? 'stack-a',
serviceName: over.serviceName ?? 'svc',
port: over.port ?? 80,
};
}
const baseStatus: MeshNodeStatus[] = [
makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'proxy', enabled: true, reverseCallbackStatus: 'connected' }),
];
describe('MeshStackTopologyView', () => {
it('renders the empty-state message when the stack publishes no aliases', () => {
render(<MeshStackTopologyView nodeId={1} stackName="api" status={baseStatus} aliases={[]} />);
expect(screen.getByText(/No published mesh services/i)).toBeInTheDocument();
expect(screen.queryByTestId('reactflow')).not.toBeInTheDocument();
});
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(<MeshStackTopologyView nodeId={1} stackName="api" status={baseStatus} aliases={aliases} />);
expect(screen.getByTestId('reactflow')).toBeInTheDocument();
expect(screen.queryByText(/No published mesh services/i)).not.toBeInTheDocument();
});
});