feat(fleet): add Fleet Actions tab for cross-node bulk operations (#963)

* feat(fleet): add Fleet Actions tab for cross-node bulk operations

Introduces a new "Actions" sub-tab in Fleet view with two Skipper+ cards
that fill gaps in the existing surface:

- Stop fleet by label: matches a label name across every node and stops
  every stack assigned to it, reporting per-node and per-stack results.
- Bulk label assign: applies the same label set to many stacks on one
  node in a single round trip.

Other bulk operations stay in their existing homes (sidebar bulk mode,
Schedules, NodeUpdatesSheet) to avoid duplicate surfaces.

Backend:
- POST /api/fleet/labels/fleet-stop (gateway-orchestrated, multi-node)
- POST /api/fleet-actions/labels/bulk-assign (per-node, capped at 1000)
- Tightens /api/fleet proxy-exempt prefix to /api/fleet/ so
  /api/fleet-actions/* is routed through the proxy for per-node calls.
- Exports activeBulkActions from labels.ts so fleet-stop and label-action
  share the per-node lock and cannot double-stop the same containers.
- Extracts containerActionForStack helper from stacks.ts for reuse.

* chore(fleet): rename Actions tab to Fleet Actions and reorder Fleet sub-tabs

- Tab label "Actions" -> "Fleet Actions" so the surface is unambiguous
  alongside Schedules and the sidebar bulk bar.
- Reorder Fleet sub-tabs as Overview / Snapshots / Status | Deployments /
  Traffic / Fleet Actions, with the separator after Status.
- Rename "Traffic · Routing" -> "Traffic" and update Sencho Mesh docs to
  match the shorter label.
- Update Fleet Actions docs to the new tab name and placement.
This commit is contained in:
Anso
2026-05-07 05:41:53 -04:00
committed by GitHub
parent 907e7427e5
commit 77d5ff58d3
16 changed files with 1074 additions and 46 deletions
+25 -16
View File
@@ -2,7 +2,7 @@ import { useExperimental } from '@/hooks/useExperimental';
import {
RefreshCw, Search, Camera,
Network, SlidersHorizontal,
Send, KeyRound, ArrowLeftRight,
Send, KeyRound, ArrowLeftRight, Wrench,
} from 'lucide-react';
import { FleetMasthead } from './fleet/FleetMasthead';
import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
@@ -23,6 +23,7 @@ import { FleetConfiguration } from './fleet/FleetConfiguration';
import { FleetSoonPlaceholder } from './fleet/FleetSoonPlaceholder';
import { RoutingTab } from './fleet/RoutingTab';
import { DeploymentsTab } from './blueprints/DeploymentsTab';
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
@@ -73,18 +74,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<Camera className="w-4 h-4 mr-1.5" />Snapshots
</TabsTrigger>
</TabsHighlightItem>
{isAdmiral && (
<TabsHighlightItem value="routing">
<TabsTrigger value="routing">
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Traffic · Routing
</TabsTrigger>
</TabsHighlightItem>
)}
<TabsHighlightItem value="configuration">
<TabsTrigger value="configuration">
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
</TabsTrigger>
</TabsHighlightItem>
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
{isPaid && (
<TabsHighlightItem value="deployments">
<TabsTrigger value="deployments">
@@ -92,9 +87,20 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</TabsTrigger>
</TabsHighlightItem>
)}
{isAdmiral && (
<TabsHighlightItem value="routing">
<TabsTrigger value="routing">
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Traffic
</TabsTrigger>
</TabsHighlightItem>
)}
<TabsHighlightItem value="actions">
<TabsTrigger value="actions">
<Wrench className="w-4 h-4 mr-1.5" />Fleet Actions
</TabsTrigger>
</TabsHighlightItem>
{experimental && (
<>
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
<TabsHighlightItem value="federation">
<TabsTrigger value="federation">
<Network className="w-4 h-4 mr-1.5" />Federation
@@ -162,13 +168,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<TabsContent value="snapshots">
<FleetSnapshots />
</TabsContent>
{isAdmiral && (
<TabsContent value="routing">
<AdmiralGate>
<RoutingTab />
</AdmiralGate>
</TabsContent>
)}
<TabsContent value="configuration">
<FleetConfiguration />
</TabsContent>
@@ -177,6 +176,16 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<DeploymentsTab />
</TabsContent>
)}
{isAdmiral && (
<TabsContent value="routing">
<AdmiralGate>
<RoutingTab />
</AdmiralGate>
</TabsContent>
)}
<TabsContent value="actions">
<FleetActionsTab nodes={overview.allNodes} />
</TabsContent>
{experimental && (
<>
<TabsContent value="federation">
@@ -0,0 +1,49 @@
import { Square, Tags } from 'lucide-react';
import { useLicense } from '@/context/LicenseContext';
import type { FleetNode } from '@/components/FleetView/types';
import { LabelFleetStopCard } from './cards/LabelFleetStopCard';
import { BulkLabelAssignCard } from './cards/BulkLabelAssignCard';
interface Props {
nodes: FleetNode[];
}
export function FleetActionsTab({ nodes }: Props) {
const { isPaid } = useLicense();
if (nodes.length === 0) {
return (
<div className="text-sm text-stat-subtitle">Add a node to the fleet to use bulk actions.</div>
);
}
if (!isPaid) {
// Both actions are Skipper+. Community users see a calm empty state with
// upgrade context rather than a stripped-down launcher.
return (
<EmptyState />
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LabelFleetStopCard nodes={nodes} accentTone="rose" icon={Square} />
<BulkLabelAssignCard nodes={nodes} accentTone="purple" icon={Tags} />
</div>
);
}
function EmptyState() {
return (
<div className="rounded-lg border border-card-border/60 bg-card p-8 text-center">
<div className="mx-auto mb-3 inline-flex h-10 w-10 items-center justify-center rounded-md bg-glass-highlight">
<Tags className="h-5 w-5 text-stat-subtitle" strokeWidth={1.5} />
</div>
<h3 className="text-sm font-medium text-stat-value mb-1">Fleet-wide bulk actions</h3>
<p className="text-xs text-stat-subtitle max-w-md mx-auto">
Stop stacks across every node by label name, and assign labels to many
stacks in one shot. Available on Skipper and Admiral.
</p>
</div>
);
}
@@ -0,0 +1,76 @@
import { CheckCircle2, XCircle, MinusCircle } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
export interface ResultRow {
key: string;
label: string;
success: boolean;
error?: string;
/** Optional secondary rows nested under this row (e.g. per-stack results
* underneath a per-node row in the fleet-stop view). */
sub?: ResultRow[];
}
interface ResultsListProps {
title?: string;
results: ResultRow[];
/** Override the empty-state message when there is nothing to render yet. */
emptyHint?: string;
}
function Row({ row, indent = 0 }: { row: ResultRow; indent?: number }) {
const Icon = row.success ? CheckCircle2 : XCircle;
const tone = row.success ? 'text-success' : 'text-destructive';
return (
<>
<div
className="flex items-start gap-2 py-1 text-sm"
style={indent ? { paddingLeft: `${indent * 16}px` } : undefined}
>
<Icon className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${tone}`} strokeWidth={1.5} />
<span className="font-mono text-xs text-stat-value">{row.label}</span>
{row.error && (
<span className="text-xs text-stat-subtitle truncate">· {row.error}</span>
)}
</div>
{row.sub?.map((child) => <Row key={child.key} row={child} indent={indent + 1} />)}
</>
);
}
export function ResultsList({ title, results, emptyHint }: ResultsListProps) {
const succeeded = results.filter(r => r.success).length;
const failed = results.length - succeeded;
return (
<Card className="bg-card shadow-card-bevel mt-4">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-medium uppercase tracking-wide text-stat-subtitle">
{title ?? 'Results'}
</span>
{results.length > 0 ? (
<>
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-success">
{succeeded} ok
</Badge>
{failed > 0 && (
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-destructive">
{failed} failed
</Badge>
)}
</>
) : (
<span className="text-xs text-stat-subtitle inline-flex items-center gap-1">
<MinusCircle className="h-3 w-3" strokeWidth={1.5} />
{emptyHint ?? 'No results yet.'}
</span>
)}
</div>
<div className="space-y-0">
{results.map((row) => <Row key={row.key} row={row} />)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,277 @@
import { useEffect, useMemo, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Loader2, Server } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ConfirmModal } from '@/components/ui/modal';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { LabelPill } from '@/components/LabelPill';
import { fetchForNode } 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';
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
interface NodeStackResult { stackName: string; success: boolean; error?: string }
interface Props {
nodes: FleetNode[];
icon: LucideIcon;
accentTone: AccentTone;
}
export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
const [selectedNodeId, setSelectedNodeId] = useState<string>(() => {
const local = nodes.find(n => n.type === 'local');
return String(local?.id ?? nodes[0]?.id ?? '');
});
const [stacks, setStacks] = useState<string[]>([]);
const [labels, setLabels] = useState<Label[]>([]);
const [loadingLists, setLoadingLists] = useState(false);
const [selectedStacks, setSelectedStacks] = useState<Set<string>>(new Set());
const [selectedLabels, setSelectedLabels] = useState<Set<number>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultRow[]>([]);
const nodeId = useMemo(() => Number(selectedNodeId) || 0, [selectedNodeId]);
const selectedNode = useMemo(() => nodes.find(n => n.id === nodeId), [nodes, nodeId]);
// Load stacks + labels whenever the node changes.
useEffect(() => {
if (!nodeId) return;
let cancelled = false;
async function load() {
setLoadingLists(true);
setSelectedStacks(new Set());
setSelectedLabels(new Set());
setResults([]);
try {
const [stacksRes, labelsRes] = await Promise.all([
fetchForNode(`/fleet/node/${nodeId}/stacks`, nodeId),
fetchForNode('/labels', nodeId),
]);
const stacksList = stacksRes.ok ? ((await stacksRes.json()) as string[]) : [];
const labelsList = labelsRes.ok ? ((await labelsRes.json()) as Label[]) : [];
if (!cancelled) {
setStacks(stacksList);
setLabels(labelsList);
}
} catch {
if (!cancelled) {
setStacks([]);
setLabels([]);
}
} finally {
if (!cancelled) setLoadingLists(false);
}
}
load();
return () => { cancelled = true; };
}, [nodeId]);
function toggleStack(stackName: string) {
setSelectedStacks(prev => {
const next = new Set(prev);
if (next.has(stackName)) next.delete(stackName);
else next.add(stackName);
return next;
});
}
function toggleLabel(labelId: number) {
setSelectedLabels(prev => {
const next = new Set(prev);
if (next.has(labelId)) next.delete(labelId);
else next.add(labelId);
return next;
});
}
function toggleAllStacks() {
if (selectedStacks.size === stacks.length) setSelectedStacks(new Set());
else setSelectedStacks(new Set(stacks));
}
async function run() {
if (selectedStacks.size === 0) return;
const labelIds = Array.from(selectedLabels);
const assignments = Array.from(selectedStacks).map(stackName => ({ stackName, labelIds }));
const toastId = toast.loading(`Assigning labels to ${assignments.length} stack${assignments.length === 1 ? '' : 's'}`);
setRunning(true);
try {
const res = await fetchForNode('/fleet-actions/labels/bulk-assign', nodeId, {
method: 'POST',
body: JSON.stringify({ assignments }),
});
const body = await res.json().catch(() => ({}));
toast.dismiss(toastId);
if (!res.ok) {
toast.error(body.error || 'Bulk label assignment failed');
return;
}
const rows: ResultRow[] = (body.results as NodeStackResult[] ?? []).map((r, i) => ({
key: `${r.stackName}-${i}`,
label: r.stackName || '(unnamed)',
success: r.success,
error: r.error,
}));
setResults(rows);
const ok = rows.filter(r => r.success).length;
const failed = rows.length - ok;
if (failed === 0) toast.success(`Updated labels on ${ok} stack${ok === 1 ? '' : 's'}.`);
else toast.warning(`${ok} updated, ${failed} failed. See results below.`);
} catch (err) {
toast.dismiss(toastId);
toast.error(err instanceof Error ? err.message : 'Network error');
} finally {
setRunning(false);
setConfirmOpen(false);
}
}
return (
<Card className="relative overflow-hidden bg-card shadow-card-bevel">
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', TONE_RAIL[accentTone])} />
<CardContent className="p-6">
<div className="flex items-start gap-3 mb-4">
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
<Icon className="h-5 w-5" strokeWidth={1.5} />
</span>
<div className="flex-1">
<h3 className="text-base font-medium text-stat-value">Bulk label assign</h3>
<p className="mt-1 text-xs text-stat-subtitle">
Pick a node, multi-select stacks, and replace their labels in one shot.
</p>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Server className="h-3.5 w-3.5 text-stat-subtitle" strokeWidth={1.5} />
<Select value={selectedNodeId} onValueChange={setSelectedNodeId} disabled={running}>
<SelectTrigger className="w-56 h-8 text-xs">
<SelectValue placeholder="Select a node" />
</SelectTrigger>
<SelectContent>
{nodes.map(n => (
<SelectItem key={n.id} value={String(n.id)}>
{n.name} {n.type === 'local' ? '(local)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{loadingLists && <span className="text-xs text-stat-subtitle">Loading</span>}
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] uppercase tracking-wide text-stat-subtitle">
Stacks ({selectedStacks.size}/{stacks.length})
</span>
{stacks.length > 0 && (
<button
type="button"
disabled={running}
onClick={toggleAllStacks}
className="text-xs text-stat-subtitle hover:text-stat-value disabled:opacity-50"
>
{selectedStacks.size === stacks.length ? 'Clear' : 'Select all'}
</button>
)}
</div>
<div className="grid gap-0.5 max-h-44 overflow-auto pr-1 border border-card-border/40 rounded-md p-2">
{stacks.length === 0 && (
<span className="text-xs text-stat-subtitle">
{loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'}
</span>
)}
{stacks.map(stackName => (
<label
key={stackName}
className="flex items-center gap-2 py-1 px-1 rounded hover:bg-glass-highlight cursor-pointer"
>
<Checkbox
checked={selectedStacks.has(stackName)}
onCheckedChange={() => toggleStack(stackName)}
disabled={running}
/>
<span className="text-xs font-mono text-stat-value">{stackName}</span>
</label>
))}
</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">
Labels ({selectedLabels.size}/{labels.length})
</div>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-auto p-2 border border-card-border/40 rounded-md">
{labels.length === 0 && (
<span className="text-xs text-stat-subtitle">
{loadingLists ? 'Loading…' : selectedNode ? `No labels defined on ${selectedNode.name}.` : ''}
</span>
)}
{labels.map(label => (
<LabelPill
key={label.id}
label={label}
active={selectedLabels.has(label.id)}
onClick={() => !running && toggleLabel(label.id)}
/>
))}
</div>
</div>
<p className="text-[11px] text-stat-subtitle">
Selected labels replace each chosen stack's existing label set on this node.
Selecting no labels clears assignments.
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={running || selectedStacks.size === 0}
onClick={() => setConfirmOpen(true)}
className="gap-2"
>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.5} /> : <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
Apply to {selectedStacks.size} stack{selectedStacks.size === 1 ? '' : 's'}
</Button>
{!running && results.length > 0 && (
<button
type="button"
onClick={() => setResults([])}
className="text-xs text-stat-subtitle hover:text-stat-value"
>
Clear results
</button>
)}
</div>
{results.length > 0 && (
<ResultsList title="Per-stack results" results={results} />
)}
</div>
<ConfirmModal
open={confirmOpen}
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="default"
kicker="Bulk label assign"
title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`}
description={
selectedLabels.size === 0
? 'No labels selected, this will clear existing assignments on the selected stacks.'
: `Each selected stack's existing label set on ${selectedNode?.name ?? 'this node'} will be replaced with the chosen labels.`
}
confirmLabel="Apply"
confirming={running}
onConfirm={run}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,198 @@
import { useEffect, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Loader2, AlertTriangle } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ConfirmModal } from '@/components/ui/modal';
import { Input } from '@/components/ui/input';
import { apiFetch, fetchForNode } 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';
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
interface NodeStackResult { stackName: string; success: boolean; error?: string }
interface FleetStopNodeResult {
nodeId: number;
nodeName: string;
matched: boolean;
stackResults: NodeStackResult[];
}
interface Props {
nodes: FleetNode[];
icon: LucideIcon;
accentTone: AccentTone;
}
export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
const [labelName, setLabelName] = useState('');
const [knownLabelNames, setKnownLabelNames] = useState<string[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false);
const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultRow[]>([]);
// Aggregate label names across reachable nodes for autocomplete. Offline
// nodes are skipped so the page-load fanout doesn't hang on dead remotes.
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 {
/* ignore — this node is unreachable, not user-facing */
}
}));
if (!cancelled) setKnownLabelNames(Array.from(names).sort());
}
loadSuggestions();
return () => { cancelled = true; };
}, [nodes]);
async function run() {
const trimmed = labelName.trim();
if (!trimmed) return;
const toastId = toast.loading(`Stopping stacks labeled "${trimmed}" across the fleet…`);
setRunning(true);
setResults([]);
try {
const res = await apiFetch('/fleet/labels/fleet-stop', {
method: 'POST',
body: JSON.stringify({ labelName: trimmed }),
});
const body = await res.json().catch(() => ({}));
toast.dismiss(toastId);
if (!res.ok) {
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'}`
: `${node.nodeName} (no matching label)`,
success: node.matched && node.stackResults.every(s => s.success),
error: node.matched ? undefined : 'Label not present',
sub: node.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 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.');
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 toast.warning(`${ok} stopped, ${failed} failed. See results below.`);
} catch (err) {
toast.dismiss(toastId);
toast.error(err instanceof Error ? err.message : 'Network error');
} finally {
setRunning(false);
setConfirmOpen(false);
}
}
return (
<Card className="relative overflow-hidden bg-card shadow-card-bevel">
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', TONE_RAIL[accentTone])} />
<CardContent className="p-6">
<div className="flex items-start gap-3 mb-4">
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
<Icon className="h-5 w-5" strokeWidth={1.5} />
</span>
<div className="flex-1">
<h3 className="text-base font-medium text-stat-value">Stop fleet by label</h3>
<p className="mt-1 text-xs text-stat-subtitle">
Stop every stack labeled with this name on every node. Labels are matched by name across the fleet.
</p>
</div>
</div>
<div className="space-y-3">
<div>
<label htmlFor="fleet-stop-label-input" className="block text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">
Label name
</label>
<Input
id="fleet-stop-label-input"
list="fleet-stop-label-suggestions"
value={labelName}
onChange={(e) => setLabelName(e.target.value)}
placeholder="e.g. production"
className="h-9 text-sm"
disabled={running}
/>
<datalist id="fleet-stop-label-suggestions">
{knownLabelNames.map(n => <option key={n} value={n} />)}
</datalist>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={running || labelName.trim().length === 0}
onClick={() => setConfirmOpen(true)}
className="gap-2"
>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.5} /> : <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
Stop matching stacks
</Button>
{!running && results.length > 0 && (
<button
type="button"
onClick={() => setResults([])}
className="text-xs text-stat-subtitle hover:text-stat-value"
>
Clear results
</button>
)}
</div>
{results.length === 0 && !running && (
<div className="rounded-md border border-card-border/40 bg-glass-highlight/30 p-3 text-xs text-stat-subtitle">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={1.5} />
<span>
Different nodes can have their own label rows. Stops are dispatched per node and report
per-stack results below.
</span>
</div>
</div>
)}
{results.length > 0 && (
<ResultsList title="Per-node breakdown" results={results} />
)}
</div>
<ConfirmModal
open={confirmOpen}
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="destructive"
kicker="Fleet stop"
title={`Stop all stacks labeled "${labelName.trim()}"?`}
description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted."
confirmLabel="Stop fleet"
confirming={running}
onConfirm={run}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,15 @@
// Tone palette shared by the Fleet Actions cards. The two cards live as
// siblings under `cards/`, so the lookup tables sit next to them rather than
// hoisting to a global tokens module.
export type AccentTone = 'rose' | 'purple';
export const TONE_RAIL: Record<AccentTone, string> = {
rose: 'bg-[var(--label-rose)]',
purple: 'bg-[var(--label-purple)]',
};
export const TONE_BG: Record<AccentTone, string> = {
rose: 'bg-[var(--label-rose-bg)] text-[var(--label-rose)]',
purple: 'bg-[var(--label-purple-bg)] text-[var(--label-purple)]',
};