mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
fix(fleet): scope Stop-by-label to stack labels with a typed suggestion source (#1368)
* fix(fleet): scope Stop-by-label to stack labels with a typed suggestion source The Fleet Actions "Stop by label" card labelled its target field generically as "Label", so a same-named node label could look like a valid stop target in a destructive workflow. The action has always matched stack labels only, but nothing in the copy or the data flow made that explicit. Add a stack-label-only suggestions endpoint and make the scope unmistakable: - New GET /api/fleet/labels/suggestions aggregates the per-node stack labels into a name-keyed list with stack and node counts (admin-only, central DB, covers every configured node including offline remotes). Node labels are never folded in. - The card now sources its autocomplete from that endpoint and renders each suggestion with its stack and node counts via a typed FleetStopLabelSuggestion model, so node-label data cannot be fed into this destructive card. - Copy is explicit throughout: "Stack label" target field with a helper line that node labels are not used, a clear "0 matching stacks" readout and a "No stacks are assigned to this stack label" empty preview, and confirm and result copy that references stacks and the stack label. - Docs updated (fleet-actions, stack-labels) and tests added on both sides, including node-only exclusion, name collision, multi-node counts, the zero-stack preview, and the non-fatal suggestions-load path. * docs: correct stale Stop-by-label button and helper references The Stop-by-label walkthrough referenced a "Stop matching stacks" button and a warning callout that no longer exist on the card. Align the docs with the live card: the primary action is "Stop fleet", and the scope is stated by the helper line under the input.
This commit is contained in:
@@ -24,7 +24,7 @@ export function FleetActionsTab({ nodes }: Props) {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px] auto-rows-fr">
|
||||
<FleetPruneCard nodes={nodes} />
|
||||
<BulkLabelAssignCard nodes={nodes} />
|
||||
<LabelFleetStopCard nodes={nodes} />
|
||||
<LabelFleetStopCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
* Locks the destructive-action contract: buttons gated on a label name, the
|
||||
* confirm modal gates the real stop, dry run bypasses the modal, per-node
|
||||
* results render, and every failure path surfaces a toast (no silent failure).
|
||||
*
|
||||
* Also locks the stack-label scope: suggestions come from the fleet
|
||||
* stack-label endpoint (never node labels), a node-only name produces a clear
|
||||
* zero-stack preview, and a non-ok suggestions response is non-fatal.
|
||||
*/
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
@@ -31,7 +35,6 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { LabelFleetStopCard } from './LabelFleetStopCard';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockedFetchForNode = fetchForNode as unknown as ReturnType<typeof vi.fn>;
|
||||
@@ -40,33 +43,127 @@ function jsonResponse(status: number, body: unknown): Response {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
const nodes = [
|
||||
{ id: 1, name: 'central', status: 'online' },
|
||||
{ id: 2, name: 'edge-1', status: 'online' },
|
||||
] as unknown as FleetNode[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Suggestion load on mount: no labels.
|
||||
mockedFetchForNode.mockResolvedValue(jsonResponse(200, []));
|
||||
// Default: preview unavailable so the debounce effect never throws.
|
||||
// Default: every endpoint 404s so the suggestions load lands empty and the
|
||||
// debounced preview never throws. Individual tests override per URL.
|
||||
mockedFetch.mockResolvedValue(jsonResponse(404, {}));
|
||||
});
|
||||
|
||||
it('disables both actions until a label name is entered', () => {
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
render(<LabelFleetStopCard />);
|
||||
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Dry run' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables the actions once a label is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'Dry run' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('labels the target as a stack label and explains node labels are excluded', () => {
|
||||
render(<LabelFleetStopCard />);
|
||||
expect(screen.getByText('Stack label · target')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Node labels are not used by this action/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sources suggestions from the fleet stack-label endpoint, not from node labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/suggestions') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
suggestions: [
|
||||
{ name: 'production', scope: 'stack', nodeCount: 2, stackCount: 3 },
|
||||
{ name: 'monitoring', scope: 'stack', nodeCount: 1, stackCount: 1 },
|
||||
],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard />);
|
||||
|
||||
// Open the popover and confirm stack labels render with their counts.
|
||||
await user.click(screen.getByPlaceholderText('e.g. production'));
|
||||
expect(await screen.findByText('production')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 stacks · 2 nodes')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 stack · 1 node')).toBeInTheDocument();
|
||||
|
||||
// A node-only label name (never returned by the stack-label endpoint) is absent,
|
||||
// and the card never reaches for the per-node label list.
|
||||
expect(screen.queryByText('edge')).not.toBeInTheDocument();
|
||||
expect(mockedFetchForNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops malformed suggestion entries that fail the stack-label shape guard', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/suggestions') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
suggestions: [
|
||||
{ name: 'missing-scope' },
|
||||
'garbage',
|
||||
{ name: 'valid', scope: 'stack', nodeCount: 1, stackCount: 1 },
|
||||
],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.click(screen.getByPlaceholderText('e.g. production'));
|
||||
expect(await screen.findByText('valid')).toBeInTheDocument();
|
||||
expect(screen.queryByText('missing-scope')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates the input when a suggestion is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/suggestions') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
suggestions: [{ name: 'production', scope: 'stack', nodeCount: 2, stackCount: 3 }],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard />);
|
||||
const input = screen.getByPlaceholderText('e.g. production') as HTMLInputElement;
|
||||
await user.click(input);
|
||||
await user.click(await screen.findByRole('button', { name: /production/ }));
|
||||
expect(input.value).toBe('production');
|
||||
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('shows a zero-stack preview when a node-only name is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/match-preview') {
|
||||
return Promise.resolve(jsonResponse(200, { matchedNodes: 0, matchedStacks: 0, perNode: [] }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
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();
|
||||
await waitFor(() => expect(screen.getByText('0 matching stacks')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('keeps the card usable when the suggestions endpoint returns 403', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/suggestions') {
|
||||
return Promise.resolve(jsonResponse(403, { error: 'Admin required' }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard />);
|
||||
// No crash, no suggestions, and the operator can still type a name by hand.
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('dry run calls fleet-stop with dryRun:true and never opens the confirm modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
@@ -77,7 +174,7 @@ it('dry run calls fleet-stop with dryRun:true and never opens the confirm modal'
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await user.click(screen.getByRole('button', { name: 'Dry run' }));
|
||||
|
||||
@@ -103,12 +200,12 @@ it('stop fleet opens the confirm modal, and confirming runs a real stop that ren
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await user.click(screen.getByRole('button', { name: 'Stop fleet' }));
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('Stop all stacks labeled "prod"?')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Stop all stacks with the stack label "prod"?')).toBeInTheDocument();
|
||||
|
||||
// The real stop must not have fired yet.
|
||||
expect(mockedFetch.mock.calls.find(c => c[0] === '/fleet/labels/fleet-stop')).toBeFalsy();
|
||||
@@ -131,7 +228,7 @@ it('surfaces an error toast when fleet-stop returns a non-ok response', async ()
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await user.click(screen.getByRole('button', { name: 'Dry run' }));
|
||||
await waitFor(() => expect(toastError).toHaveBeenCalledWith('boom'));
|
||||
@@ -145,7 +242,7 @@ it('populates the blast readout from the debounced match-preview', async () => {
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
render(<LabelFleetStopCard />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await waitFor(() => expect(screen.getByText('3 stacks · 2 nodes')).toBeInTheDocument(), { timeout: 2000 });
|
||||
});
|
||||
|
||||
@@ -3,11 +3,9 @@ import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FleetActionCard } from '@/components/ui/fleet-action-card';
|
||||
import { SheetSection } from '@/components/ui/system-sheet';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import type { Label } from '@/components/label-types';
|
||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||
|
||||
interface NodeStackResult { stackName: string; success: boolean; error?: string; dryRun?: boolean }
|
||||
@@ -18,6 +16,11 @@ interface FleetStopNodeResult {
|
||||
stackResults: NodeStackResult[];
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
interface MatchPreviewNode { nodeId: number; nodeName: string; stackCount: number; stackNames: string[] }
|
||||
interface MatchPreviewResponse { matchedNodes: number; matchedStacks: number; perNode: MatchPreviewNode[] }
|
||||
|
||||
@@ -27,42 +30,45 @@ type PreviewState =
|
||||
| { kind: 'unavailable' }
|
||||
| { kind: 'ready'; data: MatchPreviewResponse };
|
||||
|
||||
interface Props {
|
||||
nodes: FleetNode[];
|
||||
}
|
||||
|
||||
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
|
||||
const PREVIEW_ROW_LIMIT = 6;
|
||||
|
||||
export function LabelFleetStopCard({ nodes }: Props) {
|
||||
function isSuggestion(value: unknown): value is FleetStopLabelSuggestion {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const s = value as Record<string, unknown>;
|
||||
return typeof s.name === 'string' && s.scope === 'stack'
|
||||
&& typeof s.nodeCount === 'number' && typeof s.stackCount === 'number';
|
||||
}
|
||||
|
||||
export function LabelFleetStopCard() {
|
||||
const [labelName, setLabelName] = useState('');
|
||||
const [knownLabelNames, setKnownLabelNames] = useState<string[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<FleetStopLabelSuggestion[]>([]);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<ResultRow[]>([]);
|
||||
const [preview, setPreview] = useState<PreviewState>({ kind: 'idle' });
|
||||
|
||||
// Aggregate label names across reachable nodes for autocomplete.
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadSuggestions() {
|
||||
const names = new Set<string>();
|
||||
const reachable = nodes.filter(n => n.status === 'online');
|
||||
await Promise.all(reachable.map(async (node) => {
|
||||
try {
|
||||
const res = await fetchForNode('/labels', node.id);
|
||||
if (!res.ok) return;
|
||||
const list = (await res.json()) as Label[];
|
||||
for (const l of list) names.add(l.name);
|
||||
} catch {
|
||||
/* unreachable node, not user-facing */
|
||||
}
|
||||
}));
|
||||
if (!cancelled) setKnownLabelNames(Array.from(names).sort());
|
||||
try {
|
||||
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);
|
||||
} catch {
|
||||
if (!cancelled) setSuggestions([]);
|
||||
}
|
||||
}
|
||||
loadSuggestions();
|
||||
return () => { cancelled = true; };
|
||||
}, [nodes]);
|
||||
}, []);
|
||||
|
||||
// Debounced live preview. The blast-radius readout and the preview section
|
||||
// both read from the same state.
|
||||
@@ -107,7 +113,7 @@ export function LabelFleetStopCard({ nodes }: Props) {
|
||||
if (preview.kind === 'unavailable') return 'preview unavailable';
|
||||
if (preview.kind === 'ready') {
|
||||
const { matchedNodes, matchedStacks } = preview.data;
|
||||
if (matchedStacks === 0 || matchedNodes === 0) return '0 nodes match';
|
||||
if (matchedStacks === 0 || matchedNodes === 0) return '0 matching stacks';
|
||||
return `${matchedStacks} stacks · ${matchedNodes} nodes`;
|
||||
}
|
||||
return 'awaiting target';
|
||||
@@ -119,7 +125,7 @@ export function LabelFleetStopCard({ nodes }: Props) {
|
||||
const trimmed = labelName.trim();
|
||||
if (!trimmed) return;
|
||||
const verb = opts.dryRun ? 'Dry-running' : 'Stopping';
|
||||
const toastId = toast.loading(`${verb} stacks labeled "${trimmed}" across the fleet…`);
|
||||
const toastId = toast.loading(`${verb} stacks with the stack label "${trimmed}" across the fleet…`);
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
try {
|
||||
@@ -138,9 +144,9 @@ export function LabelFleetStopCard({ nodes }: Props) {
|
||||
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 label)`,
|
||||
: `${node.nodeName} (no matching stack label)`,
|
||||
success: node.matched && node.stackResults.every(s => s.success),
|
||||
error: node.matched ? undefined : 'Label not present',
|
||||
error: node.matched ? undefined : 'Stack label not present',
|
||||
sub: node.stackResults.map((s, i) => ({
|
||||
key: `${node.nodeId}-${s.stackName}-${i}`,
|
||||
label: s.stackName,
|
||||
@@ -153,10 +159,10 @@ export function LabelFleetStopCard({ nodes }: Props) {
|
||||
const stacksTouched = apiResults.flatMap(n => n.stackResults);
|
||||
const ok = stacksTouched.filter(s => s.success).length;
|
||||
const failed = stacksTouched.length - ok;
|
||||
if (matchedNodes === 0) toast.info('No nodes have a label by that name.');
|
||||
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('Label matched but no stacks were assigned to it.');
|
||||
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.`);
|
||||
} catch (err) {
|
||||
toast.dismiss(toastId);
|
||||
@@ -198,13 +204,16 @@ export function LabelFleetStopCard({ nodes }: Props) {
|
||||
footerContext={footerContext}
|
||||
>
|
||||
<SheetSection
|
||||
title="Label · target"
|
||||
meta={`auto-suggested · ${knownLabelNames.length} known`}
|
||||
title="Stack label · target"
|
||||
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.
|
||||
</p>
|
||||
<LabelAutocomplete
|
||||
value={labelName}
|
||||
onChange={setLabelName}
|
||||
suggestions={knownLabelNames}
|
||||
suggestions={suggestions}
|
||||
disabled={running}
|
||||
placeholder="e.g. production"
|
||||
/>
|
||||
@@ -224,8 +233,8 @@ export function LabelFleetStopCard({ nodes }: Props) {
|
||||
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
||||
variant="destructive"
|
||||
kicker="Fleet stop"
|
||||
title={`Stop all stacks labeled "${trimmed}"?`}
|
||||
description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted."
|
||||
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."
|
||||
confirmLabel="Stop fleet"
|
||||
confirming={running}
|
||||
onConfirm={() => run({ dryRun: false })}
|
||||
@@ -239,7 +248,7 @@ function renderPreviewSection(preview: PreviewState, trimmed: string) {
|
||||
if (preview.kind === 'loading') {
|
||||
return (
|
||||
<SheetSection title="Preview" meta="resolving…">
|
||||
<div className={cn(KICKER, 'text-stat-icon')}>looking up label across the fleet</div>
|
||||
<div className={cn(KICKER, 'text-stat-icon')}>looking up stack label across the fleet</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -255,7 +264,7 @@ function renderPreviewSection(preview: PreviewState, trimmed: string) {
|
||||
if (matchedStacks === 0) {
|
||||
return (
|
||||
<SheetSection title="Preview" meta="0 stacks">
|
||||
<div className={cn(KICKER, 'text-stat-icon')}>no node has a label by that name</div>
|
||||
<div className={cn(KICKER, 'text-stat-icon')}>No stacks are assigned to this stack label</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -307,16 +316,17 @@ function PreviewWell({ perNode }: PreviewWellProps) {
|
||||
interface LabelAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
suggestions: string[];
|
||||
suggestions: FleetStopLabelSuggestion[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// Free-form text input with a Sencho-styled suggestion popover. Replaces the
|
||||
// browser-native <datalist> so the dropdown matches the rest of the kit (same
|
||||
// surface tokens as <Combobox>). The operator can still type a label name
|
||||
// that was not in the suggestions list; the server-side match-preview will
|
||||
// resolve it or report 0 nodes match.
|
||||
// surface tokens as <Combobox>). Each suggestion is a stack label and carries
|
||||
// its stack/node counts so the scope is unmistakable. The operator can still
|
||||
// type a name that was not suggested; the server-side match-preview resolves it
|
||||
// or reports 0 matching stacks.
|
||||
function LabelAutocomplete({ value, onChange, suggestions, disabled, placeholder }: LabelAutocompleteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -324,7 +334,7 @@ function LabelAutocomplete({ value, onChange, suggestions, disabled, placeholder
|
||||
const filtered = useMemo(() => {
|
||||
const q = value.trim().toLowerCase();
|
||||
if (q.length === 0) return suggestions;
|
||||
return suggestions.filter(s => s.toLowerCase().includes(q));
|
||||
return suggestions.filter(s => s.name.toLowerCase().includes(q));
|
||||
}, [value, suggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -373,15 +383,18 @@ function LabelAutocomplete({ value, onChange, suggestions, disabled, placeholder
|
||||
<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) => (
|
||||
<li key={s}>
|
||||
<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); }}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 font-mono text-xs text-stat-value hover:bg-accent hover:text-accent-foreground"
|
||||
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"
|
||||
>
|
||||
{s}
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user