mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
fix(fleet): resolve Stop-by-label stack labels across all nodes (#1382)
* fix(fleet): resolve Stop-by-label stack labels across all nodes The Fleet Actions "Stop by label" card only ever saw the control node's own stack labels. The stack-label routes are proxied, so each node stores its labels in its own database and the control holds no mirror for remote nodes. The suggestions, match-preview, and fleet-stop endpoints all read that nonexistent mirror, so remote-only labels were invisible and remote stacks were skipped before the remote was ever asked. Make all three authoritative across the fleet with a new collectFleetLabelSummaries helper: the local node reads its own database, and each remote is queried live through its labels and label-assignments endpoints over the proxy, with fail-closed parsing and per-node reachability. fleet-stop drops the mirror pre-check and always calls each reachable remote's local-stop receiver, reporting unreachable nodes at the node level so they never block the reachable ones. The picker now surfaces remote-only labels, aggregates shared names once with combined counts and the carrying node names, and flags incomplete coverage when a node is unreachable. The preview groups matches per node, lists unreachable nodes separately, and distinguishes no matching stacks from "label exists but no stacks" from "remote unavailable". * fix(fleet): harden Stop-by-label card against malformed responses Guard the match-preview and fleet-stop response bodies so a malformed but 200 reply degrades instead of crashing or misreporting. match-preview now validates the per-node shape before rendering (the preview reads it outside any try/catch) and logs a malformed body; fleet-stop distinguishes a non-array results body (a server bug, now logged and surfaced as an unexpected-response error) from a genuine empty fleet, and guards per-node stackResults. Docs: an unreachable or errored remote is reported once per node, not as a per-stack error row.
This commit is contained in:
@@ -146,7 +146,7 @@ it('shows a zero-stack preview when a node-only name is typed', async () => {
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'edge');
|
||||
|
||||
expect(await screen.findByText('No stacks are assigned to this stack label', undefined, { timeout: 2000 })).toBeInTheDocument();
|
||||
expect(await screen.findByText('No reachable node carries a stack label by that name', undefined, { timeout: 2000 })).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('0 matching stacks')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -169,7 +169,7 @@ it('dry run calls fleet-stop with dryRun:true and never opens the confirm modal'
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/fleet-stop') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
results: [{ nodeId: 1, nodeName: 'central', matched: true, stackResults: [{ stackName: 'web', success: true, dryRun: true }] }],
|
||||
results: [{ nodeId: 1, nodeName: 'central', reachable: true, matched: true, stackResults: [{ stackName: 'web', success: true, dryRun: true }] }],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
@@ -193,8 +193,8 @@ it('stop fleet opens the confirm modal, and confirming runs a real stop that ren
|
||||
if (url === '/fleet/labels/fleet-stop') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
results: [
|
||||
{ nodeId: 1, nodeName: 'central', matched: true, stackResults: [{ stackName: 'web', success: true }] },
|
||||
{ nodeId: 2, nodeName: 'edge-1', matched: false, stackResults: [] },
|
||||
{ nodeId: 1, nodeName: 'central', reachable: true, matched: true, stackResults: [{ stackName: 'web', success: true }] },
|
||||
{ nodeId: 2, nodeName: 'edge-1', reachable: true, matched: false, stackResults: [] },
|
||||
],
|
||||
}));
|
||||
}
|
||||
@@ -238,7 +238,7 @@ it('populates the blast readout from the debounced match-preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/match-preview') {
|
||||
return Promise.resolve(jsonResponse(200, { matchedNodes: 2, matchedStacks: 3, perNode: [] }));
|
||||
return Promise.resolve(jsonResponse(200, { matchedNodes: 2, matchedStacks: 3, unreachableNodes: 0, perNode: [] }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
@@ -246,3 +246,120 @@ it('populates the blast readout from the debounced match-preview', async () => {
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await waitFor(() => expect(screen.getByText('3 stacks · 2 nodes')).toBeInTheDocument(), { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('degrades to "preview unavailable" when match-preview returns a malformed 200 body', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/match-preview') {
|
||||
// 200 but perNode is missing: the render path must degrade, not throw.
|
||||
return Promise.resolve(jsonResponse(200, { matchedStacks: 1, matchedNodes: 1 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
expect(await screen.findByText('preview endpoint did not respond', undefined, { timeout: 2000 })).toBeInTheDocument();
|
||||
// The card stays interactive; no crash from the missing perNode array.
|
||||
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('does not crash when fleet-stop returns a malformed 200 body', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/fleet-stop') {
|
||||
// results is not an array: must degrade to a toast, not throw.
|
||||
return Promise.resolve(jsonResponse(200, { results: 'nope' }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await user.click(screen.getByRole('button', { name: 'Dry run' }));
|
||||
// Reported as an unexpected response, not masqueraded as "No reachable nodes".
|
||||
await waitFor(() => expect(toastError).toHaveBeenCalledWith('Fleet stop returned an unexpected response. Check the server logs and retry.'));
|
||||
expect(toastWarning).not.toHaveBeenCalled();
|
||||
expect(screen.getByPlaceholderText('e.g. production')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows remote labels with their node spread and flags partial coverage', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/suggestions') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
suggestions: [{ name: 'media', scope: 'stack', nodeCount: 2, stackCount: 3, nodes: ['central', 'edge-1'] }],
|
||||
unreachableNodes: 1,
|
||||
partial: true,
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard />);
|
||||
expect(await screen.findByText(/1 node unreachable; suggestions may be incomplete/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByPlaceholderText('e.g. production'));
|
||||
expect(await screen.findByText('media')).toBeInTheDocument();
|
||||
// The node-name spread renders as a muted detail under the label name.
|
||||
expect(screen.getByText('central, edge-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders unreachable nodes in the preview without dropping the matched ones', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/match-preview') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
matchedNodes: 1,
|
||||
matchedStacks: 1,
|
||||
unreachableNodes: 1,
|
||||
perNode: [
|
||||
{ nodeId: 1, nodeName: 'central', reachable: true, labelExists: true, stackCount: 1, stackNames: ['web'] },
|
||||
{ nodeId: 2, nodeName: 'edge-1', reachable: false, labelExists: false, stackCount: 0, stackNames: [], error: 'Remote node not configured' },
|
||||
],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'media');
|
||||
expect(await screen.findByText('web', undefined, { timeout: 2000 })).toBeInTheDocument();
|
||||
expect(screen.getByText('unreachable · 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('edge-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('distinguishes "label exists, no stacks" and shows a 0-reachable blast when the rest are unreachable', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/match-preview') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
matchedNodes: 0,
|
||||
matchedStacks: 0,
|
||||
unreachableNodes: 1,
|
||||
perNode: [
|
||||
{ nodeId: 1, nodeName: 'central', reachable: true, labelExists: true, stackCount: 0, stackNames: [] },
|
||||
{ nodeId: 2, nodeName: 'edge-1', reachable: false, labelExists: false, stackCount: 0, stackNames: [], error: 'Remote node not configured' },
|
||||
],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'media');
|
||||
expect(await screen.findByText('This stack label exists but has no stacks assigned on the reachable nodes', undefined, { timeout: 2000 })).toBeInTheDocument();
|
||||
expect(screen.getByText('unreachable · 1')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('0 reachable · 1 unreachable')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders an unreachable fleet-stop node as unreachable, not "no matching label", and warns', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/fleet-stop') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
results: [{ nodeId: 2, nodeName: 'edge-1', reachable: false, matched: false, stackResults: [], error: 'Remote node not configured' }],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await user.click(screen.getByRole('button', { name: 'Dry run' }));
|
||||
expect(await screen.findByText(/edge-1 \(unreachable\)/)).toBeInTheDocument();
|
||||
await waitFor(() => expect(toastWarning).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
@@ -12,17 +12,20 @@ interface NodeStackResult { stackName: string; success: boolean; error?: string;
|
||||
interface FleetStopNodeResult {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
reachable: boolean;
|
||||
matched: boolean;
|
||||
stackResults: NodeStackResult[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Stop-by-label targets stack labels only. The `scope: 'stack'` tag keeps node
|
||||
// labels (a separate namespace) from ever being fed into this destructive card,
|
||||
// and the counts make the stack scope tangible in the picker.
|
||||
interface FleetStopLabelSuggestion { name: string; scope: 'stack'; nodeCount: number; stackCount: number }
|
||||
// and the counts make the stack scope tangible in the picker. `nodes` lists the
|
||||
// nodes that carry the label so the operator can see the spread before acting.
|
||||
interface FleetStopLabelSuggestion { name: string; scope: 'stack'; nodeCount: number; stackCount: number; nodes: string[] }
|
||||
|
||||
interface MatchPreviewNode { nodeId: number; nodeName: string; stackCount: number; stackNames: string[] }
|
||||
interface MatchPreviewResponse { matchedNodes: number; matchedStacks: number; perNode: MatchPreviewNode[] }
|
||||
interface MatchPreviewNode { nodeId: number; nodeName: string; reachable: boolean; labelExists: boolean; stackCount: number; stackNames: string[]; error?: string }
|
||||
interface MatchPreviewResponse { matchedNodes: number; matchedStacks: number; unreachableNodes: number; perNode: MatchPreviewNode[] }
|
||||
|
||||
type PreviewState =
|
||||
| { kind: 'idle' }
|
||||
@@ -36,24 +39,50 @@ const PREVIEW_ROW_LIMIT = 6;
|
||||
function isSuggestion(value: unknown): value is FleetStopLabelSuggestion {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const s = value as Record<string, unknown>;
|
||||
// `nodes` is tolerated as absent (coerced to [] at render) so a suggestion is
|
||||
// never dropped over a missing optional detail field.
|
||||
const nodesOk = s.nodes === undefined
|
||||
|| (Array.isArray(s.nodes) && s.nodes.every(n => typeof n === 'string'));
|
||||
return typeof s.name === 'string' && s.scope === 'stack'
|
||||
&& typeof s.nodeCount === 'number' && typeof s.stackCount === 'number';
|
||||
&& typeof s.nodeCount === 'number' && typeof s.stackCount === 'number' && nodesOk;
|
||||
}
|
||||
|
||||
// The preview section renders `perNode` (filter/some/flatMap) outside any
|
||||
// try/catch, so a malformed success body would throw during render rather than
|
||||
// degrade. Validate the load-bearing shape before trusting it; anything off
|
||||
// falls to the "unavailable" state.
|
||||
function isMatchPreviewResponse(value: unknown): value is MatchPreviewResponse {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const d = value as Record<string, unknown>;
|
||||
if (typeof d.matchedNodes !== 'number' || typeof d.matchedStacks !== 'number') return false;
|
||||
if (!Array.isArray(d.perNode)) return false;
|
||||
return d.perNode.every((n) => {
|
||||
if (typeof n !== 'object' || n === null) return false;
|
||||
const node = n as Record<string, unknown>;
|
||||
return typeof node.nodeId === 'number'
|
||||
&& typeof node.nodeName === 'string'
|
||||
&& typeof node.reachable === 'boolean'
|
||||
&& typeof node.stackCount === 'number'
|
||||
&& Array.isArray(node.stackNames);
|
||||
});
|
||||
}
|
||||
|
||||
export function LabelFleetStopCard() {
|
||||
const [labelName, setLabelName] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<FleetStopLabelSuggestion[]>([]);
|
||||
const [suggestUnreachable, setSuggestUnreachable] = useState(0);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<ResultRow[]>([]);
|
||||
const [preview, setPreview] = useState<PreviewState>({ kind: 'idle' });
|
||||
|
||||
// Stack-label suggestions for the target picker. The fleet endpoint aggregates
|
||||
// the stack_labels rows across every configured node (central DB), so node
|
||||
// labels can never appear here. A non-ok or malformed response leaves the list
|
||||
// empty rather than crashing: the Actions tab renders without an admin gate
|
||||
// while the endpoint is admin-only, so a viewer simply gets no suggestions and
|
||||
// can still type a name by hand.
|
||||
// Stack-label suggestions for the target picker. The fleet endpoint queries
|
||||
// every node authoritatively (local DB + live remote reads), so node labels
|
||||
// can never appear here and remote-only stack labels do. `unreachableNodes`
|
||||
// flags that the counts cover only the nodes Sencho could reach. A non-ok or
|
||||
// malformed response leaves the list empty rather than crashing: the Actions
|
||||
// tab renders without an admin gate while the endpoint is admin-only, so a
|
||||
// viewer simply gets no suggestions and can still type a name by hand.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadSuggestions() {
|
||||
@@ -61,9 +90,12 @@ export function LabelFleetStopCard() {
|
||||
const res = await apiFetch('/fleet/labels/suggestions');
|
||||
const data = res.ok ? await res.json().catch(() => null) : null;
|
||||
const list = Array.isArray(data?.suggestions) ? data.suggestions.filter(isSuggestion) : [];
|
||||
if (!cancelled) setSuggestions(list);
|
||||
if (!cancelled) {
|
||||
setSuggestions(list);
|
||||
setSuggestUnreachable(typeof data?.unreachableNodes === 'number' ? data.unreachableNodes : 0);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setSuggestions([]);
|
||||
if (!cancelled) { setSuggestions([]); setSuggestUnreachable(0); }
|
||||
}
|
||||
}
|
||||
loadSuggestions();
|
||||
@@ -95,9 +127,20 @@ export function LabelFleetStopCard() {
|
||||
setPreview({ kind: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as MatchPreviewResponse;
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!isMatchPreviewResponse(data)) {
|
||||
// A 200 with a shape we can't render is a server/contract bug, not a
|
||||
// transport miss; log it like the catch path so it's diagnosable.
|
||||
console.error('[FleetStop] match-preview returned a malformed body', data);
|
||||
setPreview({ kind: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
setPreview({ kind: 'ready', data });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Non-destructive readout: the operator can still type a name and run the
|
||||
// stop, so we degrade to "unavailable" rather than toasting, but leave a
|
||||
// console trail so a recurring preview failure is diagnosable.
|
||||
console.error('[FleetStop] match-preview failed', err);
|
||||
setPreview({ kind: 'unavailable' });
|
||||
}
|
||||
}, 500);
|
||||
@@ -112,9 +155,12 @@ export function LabelFleetStopCard() {
|
||||
if (preview.kind === 'loading') return 'resolving…';
|
||||
if (preview.kind === 'unavailable') return 'preview unavailable';
|
||||
if (preview.kind === 'ready') {
|
||||
const { matchedNodes, matchedStacks } = preview.data;
|
||||
if (matchedStacks === 0 || matchedNodes === 0) return '0 matching stacks';
|
||||
return `${matchedStacks} stacks · ${matchedNodes} nodes`;
|
||||
const { matchedNodes, matchedStacks, unreachableNodes } = preview.data;
|
||||
if (matchedStacks === 0 || matchedNodes === 0) {
|
||||
return unreachableNodes > 0 ? `0 reachable · ${unreachableNodes} unreachable` : '0 matching stacks';
|
||||
}
|
||||
const suffix = unreachableNodes > 0 ? ` · ${unreachableNodes} unreachable` : '';
|
||||
return `${matchedStacks} stacks · ${matchedNodes} nodes${suffix}`;
|
||||
}
|
||||
return 'awaiting target';
|
||||
}, [labelName, preview]);
|
||||
@@ -139,31 +185,59 @@ export function LabelFleetStopCard() {
|
||||
toast.error(body.error || 'Fleet stop failed');
|
||||
return;
|
||||
}
|
||||
const apiResults = (body.results as FleetStopNodeResult[]) ?? [];
|
||||
const rows: ResultRow[] = apiResults.map((node) => ({
|
||||
key: `node-${node.nodeId}`,
|
||||
label: node.matched
|
||||
? `${node.nodeName} · ${node.stackResults.length} stack${node.stackResults.length === 1 ? '' : 's'}${opts.dryRun ? ' (dry run)' : ''}`
|
||||
: `${node.nodeName} (no matching stack label)`,
|
||||
success: node.matched && node.stackResults.every(s => s.success),
|
||||
error: node.matched ? undefined : 'Stack label not present',
|
||||
sub: node.stackResults.map((s, i) => ({
|
||||
key: `${node.nodeId}-${s.stackName}-${i}`,
|
||||
label: s.stackName,
|
||||
success: s.success,
|
||||
error: s.error,
|
||||
})),
|
||||
}));
|
||||
// A 200 with a non-array `results` is a server/contract bug, not an empty
|
||||
// fleet: don't let it masquerade as the legitimate "No reachable nodes"
|
||||
// outcome (a valid empty array below still reports that honestly). Log it
|
||||
// and tell the operator it was unexpected. A node's `stackResults` is
|
||||
// guarded per node further down so one bad node never nukes the readout.
|
||||
if (!Array.isArray(body.results)) {
|
||||
console.error('[FleetStop] fleet-stop returned a malformed body', body);
|
||||
toast.error('Fleet stop returned an unexpected response. Check the server logs and retry.');
|
||||
return;
|
||||
}
|
||||
const apiResults = body.results as FleetStopNodeResult[];
|
||||
const rows: ResultRow[] = apiResults.map((node) => {
|
||||
if (!node.reachable) {
|
||||
return {
|
||||
key: `node-${node.nodeId}`,
|
||||
label: `${node.nodeName} (unreachable)`,
|
||||
success: false,
|
||||
error: node.error || 'Node unreachable',
|
||||
sub: [],
|
||||
};
|
||||
}
|
||||
const stackResults = Array.isArray(node.stackResults) ? node.stackResults : [];
|
||||
return {
|
||||
key: `node-${node.nodeId}`,
|
||||
label: node.matched
|
||||
? `${node.nodeName} · ${stackResults.length} stack${stackResults.length === 1 ? '' : 's'}${opts.dryRun ? ' (dry run)' : ''}`
|
||||
: `${node.nodeName} (no matching stack label)`,
|
||||
success: node.matched && stackResults.every(s => s.success),
|
||||
error: node.matched ? undefined : 'Stack label not present',
|
||||
sub: stackResults.map((s, i) => ({
|
||||
key: `${node.nodeId}-${s.stackName}-${i}`,
|
||||
label: s.stackName,
|
||||
success: s.success,
|
||||
error: s.error,
|
||||
})),
|
||||
};
|
||||
});
|
||||
setResults(rows);
|
||||
const matchedNodes = apiResults.filter(n => n.matched).length;
|
||||
const stacksTouched = apiResults.flatMap(n => n.stackResults);
|
||||
const reachable = apiResults.filter(n => n.reachable);
|
||||
const unreachableCount = apiResults.length - reachable.length;
|
||||
const matchedNodes = reachable.filter(n => n.matched).length;
|
||||
const stacksTouched = apiResults.flatMap(n => Array.isArray(n.stackResults) ? n.stackResults : []);
|
||||
const ok = stacksTouched.filter(s => s.success).length;
|
||||
const failed = stacksTouched.length - ok;
|
||||
if (matchedNodes === 0) toast.info('No node carries a stack label by that name.');
|
||||
else if (opts.dryRun) toast.success(`Dry run: would stop ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`);
|
||||
else if (failed === 0 && ok > 0) toast.success(`Stopped ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`);
|
||||
else if (ok === 0 && failed === 0) toast.info('Stack label matched but no stacks were assigned to it.');
|
||||
else toast.warning(`${ok} stopped, ${failed} failed. See results below.`);
|
||||
const unreachableSuffix = unreachableCount > 0 ? ` ${unreachableCount} node${unreachableCount === 1 ? '' : 's'} unreachable.` : '';
|
||||
if (matchedNodes === 0) {
|
||||
if (reachable.length === 0) toast.warning(`No reachable nodes.${unreachableSuffix}`);
|
||||
else toast.info(`No reachable node carries a stack label by that name.${unreachableSuffix}`);
|
||||
}
|
||||
else if (opts.dryRun) toast.success(`Dry run: would stop ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.${unreachableSuffix}`);
|
||||
else if (failed === 0 && ok > 0) toast.success(`Stopped ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.${unreachableSuffix}`);
|
||||
else if (ok === 0 && failed === 0) toast.info(`Stack label matched but no stacks were assigned to it.${unreachableSuffix}`);
|
||||
else toast.warning(`${ok} stopped, ${failed} failed. See results below.${unreachableSuffix}`);
|
||||
} catch (err) {
|
||||
toast.dismiss(toastId);
|
||||
toast.error(err instanceof Error ? err.message : 'Network error');
|
||||
@@ -208,8 +282,13 @@ export function LabelFleetStopCard() {
|
||||
meta={`stack labels · ${suggestions.length}`}
|
||||
>
|
||||
<p className={cn(KICKER, 'text-stat-subtitle mb-2 normal-case tracking-normal text-[11px]')}>
|
||||
Stops stacks assigned to this stack label across matching nodes. Node labels are not used by this action.
|
||||
Stops every stack assigned this stack label on every reachable node across the fleet. Node labels are not used by this action.
|
||||
</p>
|
||||
{suggestUnreachable > 0 && (
|
||||
<p className={cn(KICKER, 'text-stat-icon mb-2 normal-case tracking-normal text-[11px]')}>
|
||||
{suggestUnreachable} node{suggestUnreachable === 1 ? '' : 's'} unreachable; suggestions may be incomplete.
|
||||
</p>
|
||||
)}
|
||||
<LabelAutocomplete
|
||||
value={labelName}
|
||||
onChange={setLabelName}
|
||||
@@ -234,7 +313,7 @@ export function LabelFleetStopCard() {
|
||||
variant="destructive"
|
||||
kicker="Fleet stop"
|
||||
title={`Stop all stacks with the stack label "${trimmed}"?`}
|
||||
description="Sencho will stop every stack assigned this stack label on every node. Node labels are not used by this action. Services will be unavailable until restarted."
|
||||
description="Sencho will stop every stack assigned this stack label on every reachable node at execution time. Node labels are not used by this action. Services will be unavailable until restarted."
|
||||
confirmLabel="Stop fleet"
|
||||
confirming={running}
|
||||
onConfirm={() => run({ dryRun: false })}
|
||||
@@ -260,26 +339,55 @@ function renderPreviewSection(preview: PreviewState, trimmed: string) {
|
||||
);
|
||||
}
|
||||
if (preview.kind === 'ready') {
|
||||
const { matchedStacks, matchedNodes, perNode } = preview.data;
|
||||
const { matchedStacks, matchedNodes, unreachableNodes, perNode } = preview.data;
|
||||
const unreachable = perNode.filter(n => !n.reachable);
|
||||
const matchingNodes = perNode.filter(n => n.reachable && n.stackCount > 0);
|
||||
if (matchedStacks === 0) {
|
||||
// Distinguish "label exists but no stacks" from "no such label", and still
|
||||
// surface unreachable nodes so a 0-count never hides a node we could not ask.
|
||||
const labelExistsSomewhere = perNode.some(n => n.reachable && n.labelExists);
|
||||
const message = labelExistsSomewhere
|
||||
? 'This stack label exists but has no stacks assigned on the reachable nodes'
|
||||
: 'No reachable node carries a stack label by that name';
|
||||
return (
|
||||
<SheetSection title="Preview" meta="0 stacks">
|
||||
<div className={cn(KICKER, 'text-stat-icon')}>No stacks are assigned to this stack label</div>
|
||||
<SheetSection title="Preview" meta={unreachableNodes > 0 ? `${unreachableNodes} unreachable` : '0 stacks'}>
|
||||
<div className={cn(KICKER, 'text-stat-icon normal-case tracking-normal text-[11px]')}>{message}</div>
|
||||
{unreachable.length > 0 && <UnreachableList nodes={unreachable} />}
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
const meta = `across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}${unreachableNodes > 0 ? ` · ${unreachableNodes} unreachable` : ''}`;
|
||||
return (
|
||||
<SheetSection
|
||||
title={`Preview · ${matchedStacks} stack${matchedStacks === 1 ? '' : 's'}`}
|
||||
meta={`across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}`}
|
||||
meta={meta}
|
||||
>
|
||||
<PreviewWell perNode={perNode} />
|
||||
<PreviewWell perNode={matchingNodes} />
|
||||
{unreachable.length > 0 && <UnreachableList nodes={unreachable} />}
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Muted list of nodes Sencho could not query, shown beneath the preview so a
|
||||
// partial blast radius is observable rather than silently dropped.
|
||||
function UnreachableList({ nodes }: { nodes: MatchPreviewNode[] }) {
|
||||
return (
|
||||
<div className="mt-2 rounded border border-card-border/60 bg-card/40 p-2">
|
||||
<div className={cn(KICKER, 'text-stat-icon mb-1')}>unreachable · {nodes.length}</div>
|
||||
<ul className="space-y-1">
|
||||
{nodes.map(n => (
|
||||
<li key={n.nodeId} className="flex items-center gap-2">
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-[11px] text-stat-subtitle">{n.nodeName}</span>
|
||||
{n.error && <span className={cn(KICKER, 'shrink-0 max-w-[55%] truncate text-stat-icon')}>{n.error}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PreviewWellProps {
|
||||
perNode: MatchPreviewNode[];
|
||||
}
|
||||
@@ -382,22 +490,31 @@ function LabelAutocomplete({ value, onChange, suggestions, disabled, placeholder
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute left-0 top-full mt-1 z-50 w-full rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]">
|
||||
<ul className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
|
||||
{filtered.map((s) => (
|
||||
{filtered.map((s) => {
|
||||
const nodes = s.nodes ?? [];
|
||||
return (
|
||||
<li key={s.name}>
|
||||
<button
|
||||
type="button"
|
||||
// mousedown + preventDefault keeps the input focused so the
|
||||
// selection registers before any blur-driven close fires.
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(s.name); }}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 font-mono text-xs text-stat-value hover:bg-accent hover:text-accent-foreground"
|
||||
title={nodes.join(', ')}
|
||||
className="flex w-full flex-col gap-0.5 rounded-sm px-2 py-1.5 font-mono text-xs text-stat-value hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate text-left">{s.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-stat-subtitle">
|
||||
{s.stackCount} stack{s.stackCount === 1 ? '' : 's'} · {s.nodeCount} node{s.nodeCount === 1 ? '' : 's'}
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<span className="flex-1 min-w-0 truncate text-left">{s.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-stat-subtitle">
|
||||
{s.stackCount} stack{s.stackCount === 1 ? '' : 's'} · {s.nodeCount} node{s.nodeCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</span>
|
||||
{nodes.length > 0 && (
|
||||
<span className="w-full truncate text-left text-[10px] text-stat-icon">{nodes.join(', ')}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user