mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
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:
@@ -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)]',
|
||||
};
|
||||
Reference in New Issue
Block a user