mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
81858a0bb0
* 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.
145 lines
6.2 KiB
TypeScript
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();
|
|
});
|
|
});
|