From a41af47ff3506afd4fa51b11e390308fd41daf44 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 20 Apr 2026 12:56:00 -0400 Subject: [PATCH] feat(fleet): aggregate labels across nodes and allow remote edits (#710) Labels settings are now visible on remote nodes (scope: node, no longer hidden on remote context). The LabelsSection already routes through the active-node proxy, so edits land on whichever node the operator is viewing. Fleet overview's label filter previously fetched only the control-plane node's labels and assignments, so remote stacks could never match the filter. Rewrote aggregation to fan out /labels and /labels/assignments to every online node via fetchForNode + Promise.allSettled with a 5s timeout per request. The palette dedupes by (name, color) so identical labels on multiple nodes collapse into one entry while same-name + different-color stay distinct. The assignment map is nested by nodeId to avoid cross-node stack-name collisions. Keyed the label refetch effect on a stable online-node id signature rather than the nodes array reference, so the existing 30s overview poll (and 5s fast-poll during updates) does not cascade into repeated fleet-wide label fetches. --- frontend/src/components/FleetView.tsx | 110 +++++++++++++------ frontend/src/components/settings/registry.ts | 5 +- 2 files changed, 79 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 0601443f..d091c199 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -22,13 +22,23 @@ import { } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; import { springs } from '@/lib/motion'; -import { apiFetch } from '@/lib/api'; +import { apiFetch, fetchForNode } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; import { PaidGate } from './PaidGate'; import FleetSnapshots from './FleetSnapshots'; import { toast } from '@/components/ui/toast-store'; import { LabelDot } from './LabelPill'; -import { type Label as StackLabel } from './label-types'; +import { type Label as StackLabel, type LabelColor } from './label-types'; + +interface FleetPaletteEntry { + key: string; + name: string; + color: LabelColor; +} + +function labelPaletteKey(name: string, color: LabelColor): string { + return `${name.trim().toLowerCase()}|${color}`; +} import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; import { formatVersion } from '@/lib/version'; import { CursorProvider, Cursor, CursorFollow, CursorContainer } from '@/components/animate-ui/primitives/animate/cursor'; @@ -654,9 +664,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const [lastSyncAt, setLastSyncAt] = useState(null); const [viewMode, setViewMode] = useState<'grid' | 'topology'>('grid'); const [prefs, setPrefs] = useState(loadPreferences); - const [fleetLabels, setFleetLabels] = useState([]); - const [fleetStackLabelMap, setFleetStackLabelMap] = useState>({}); - const [labelFilters, setLabelFilters] = useState>(new Set()); + const [fleetPalette, setFleetPalette] = useState([]); + const [fleetStackLabelMap, setFleetStackLabelMap] = useState>>({}); + const [labelFilters, setLabelFilters] = useState>(new Set()); const { isPaid } = useLicense(); const [updateStatuses, setUpdateStatuses] = useState([]); const [updatingNodeId, setUpdatingNodeId] = useState(null); @@ -694,18 +704,38 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { } }, []); - const fetchLabels = useCallback(async () => { - if (!isPaid) return; - try { - const [labelsRes, assignmentsRes] = await Promise.all([ - apiFetch('/labels', { localOnly: true }), - apiFetch('/labels/assignments', { localOnly: true }), - ]); - if (labelsRes.ok) setFleetLabels(await labelsRes.json()); - if (assignmentsRes.ok) setFleetStackLabelMap(await assignmentsRes.json()); - } catch { - // Non-critical - } + const fetchLabelsForNodes = useCallback(async (fleetNodes: FleetNode[]) => { + if (!isPaid || fleetNodes.length === 0) return; + + const paletteMap = new Map(); + const stackLabelMap: Record> = {}; + + await Promise.allSettled(fleetNodes.map(async (node) => { + if (node.status !== 'online') return; + try { + const [labelsRes, assignmentsRes] = await Promise.all([ + fetchForNode('/labels', node.id, { signal: AbortSignal.timeout(5000) }), + fetchForNode('/labels/assignments', node.id, { signal: AbortSignal.timeout(5000) }), + ]); + if (labelsRes.ok) { + const labels = await labelsRes.json() as StackLabel[]; + for (const l of labels) { + const key = labelPaletteKey(l.name, l.color); + if (!paletteMap.has(key)) { + paletteMap.set(key, { key, name: l.name, color: l.color }); + } + } + } + if (assignmentsRes.ok) { + stackLabelMap[node.id] = await assignmentsRes.json() as Record; + } + } catch { + // Node unreachable or slow: skip, other nodes still contribute. + } + })); + + setFleetPalette(Array.from(paletteMap.values()).sort((a, b) => a.name.localeCompare(b.name))); + setFleetStackLabelMap(stackLabelMap); }, [isPaid]); const fetchUpdateStatus = useCallback(async () => { @@ -814,9 +844,21 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { useEffect(() => { fetchOverview(); - fetchLabels(); fetchUpdateStatus(); - }, [fetchOverview, fetchLabels, fetchUpdateStatus]); + }, [fetchOverview, fetchUpdateStatus]); + + // Refetch labels only when the set of online nodes actually changes, + // not on every `fetchOverview` tick (which mints a new `nodes` ref). + const onlineNodeKey = nodes + .filter(n => n.status === 'online') + .map(n => n.id) + .sort((a, b) => a - b) + .join(','); + useEffect(() => { + if (!isPaid || nodes.length === 0) return; + fetchLabelsForNodes(nodes); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isPaid, onlineNodeKey, fetchLabelsForNodes]); // Paid tier: auto-refresh every 30s useEffect(() => { @@ -900,14 +942,16 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { // Critical filter if (prefs.filterCritical) filtered = filtered.filter(isCritical); - // Label filter + // Label filter: match by (name, color) palette key so equivalent + // labels across nodes behave as one filter. if (labelFilters.size > 0) { - filtered = filtered.filter(n => - n.stacks?.some(s => { - const sLabels = fleetStackLabelMap[s] || []; - return sLabels.some(l => labelFilters.has(l.id)); - }) - ); + filtered = filtered.filter(n => { + const nodeStackLabels = fleetStackLabelMap[n.id] ?? {}; + return n.stacks?.some(s => { + const sLabels = nodeStackLabels[s] ?? []; + return sLabels.some(l => labelFilters.has(labelPaletteKey(l.name, l.color))); + }); + }); } // Sort @@ -1147,17 +1191,17 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { Critical Only - {fleetLabels.length > 0 && ( + {fleetPalette.length > 0 && ( <>
({ value: String(l.id), label: l.name, color: l.color }))} - selected={new Set(Array.from(labelFilters).map(String))} - onSelectionChange={(sel) => setLabelFilters(new Set(Array.from(sel).map(Number)))} + options={fleetPalette.map(p => ({ value: p.key, label: p.name, color: p.color }))} + selected={labelFilters} + onSelectionChange={setLabelFilters} placeholder="Tags" renderOption={(option) => ( - + {option.label} )} @@ -1181,7 +1225,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { key={localNode.id} node={localNode} onNavigate={onNavigateToNode} - labelMap={fleetStackLabelMap} + labelMap={fleetStackLabelMap[localNode.id] ?? {}} updateStatus={updateStatusMap.get(localNode.id)} onUpdate={isPaid ? triggerNodeUpdate : undefined} updatingNodeId={updatingNodeId} @@ -1197,7 +1241,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { key={node.id} node={node} onNavigate={onNavigateToNode} - labelMap={fleetStackLabelMap} + labelMap={fleetStackLabelMap[node.id] ?? {}} updateStatus={updateStatusMap.get(node.id)} onUpdate={isPaid ? triggerNodeUpdate : undefined} updatingNodeId={updatingNodeId} diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 6dd5162e..9bef8c23 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -158,11 +158,10 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ id: 'labels', group: 'advanced', label: 'Labels', - description: 'Shared labels for stacks, containers, and nodes.', + description: 'Per-node labels for stacks and containers.', keywords: ['labels', 'tags', 'palette', 'organisation'], tier: 'skipper', - scope: 'global', - hiddenOnRemote: true, + scope: 'node', }, { id: 'security',