mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
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.
This commit is contained in:
@@ -22,13 +22,23 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
||||||
import { springs } from '@/lib/motion';
|
import { springs } from '@/lib/motion';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||||
import { useLicense } from '@/context/LicenseContext';
|
import { useLicense } from '@/context/LicenseContext';
|
||||||
import { PaidGate } from './PaidGate';
|
import { PaidGate } from './PaidGate';
|
||||||
import FleetSnapshots from './FleetSnapshots';
|
import FleetSnapshots from './FleetSnapshots';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { LabelDot } from './LabelPill';
|
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 { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||||
import { formatVersion } from '@/lib/version';
|
import { formatVersion } from '@/lib/version';
|
||||||
import { CursorProvider, Cursor, CursorFollow, CursorContainer } from '@/components/animate-ui/primitives/animate/cursor';
|
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<number | null>(null);
|
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'topology'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'topology'>('grid');
|
||||||
const [prefs, setPrefs] = useState<FleetPreferences>(loadPreferences);
|
const [prefs, setPrefs] = useState<FleetPreferences>(loadPreferences);
|
||||||
const [fleetLabels, setFleetLabels] = useState<StackLabel[]>([]);
|
const [fleetPalette, setFleetPalette] = useState<FleetPaletteEntry[]>([]);
|
||||||
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<string, StackLabel[]>>({});
|
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<number, Record<string, StackLabel[]>>>({});
|
||||||
const [labelFilters, setLabelFilters] = useState<Set<number>>(new Set());
|
const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set());
|
||||||
const { isPaid } = useLicense();
|
const { isPaid } = useLicense();
|
||||||
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
|
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
|
||||||
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
|
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
|
||||||
@@ -694,18 +704,38 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchLabels = useCallback(async () => {
|
const fetchLabelsForNodes = useCallback(async (fleetNodes: FleetNode[]) => {
|
||||||
if (!isPaid) return;
|
if (!isPaid || fleetNodes.length === 0) return;
|
||||||
try {
|
|
||||||
const [labelsRes, assignmentsRes] = await Promise.all([
|
const paletteMap = new Map<string, FleetPaletteEntry>();
|
||||||
apiFetch('/labels', { localOnly: true }),
|
const stackLabelMap: Record<number, Record<string, StackLabel[]>> = {};
|
||||||
apiFetch('/labels/assignments', { localOnly: true }),
|
|
||||||
]);
|
await Promise.allSettled(fleetNodes.map(async (node) => {
|
||||||
if (labelsRes.ok) setFleetLabels(await labelsRes.json());
|
if (node.status !== 'online') return;
|
||||||
if (assignmentsRes.ok) setFleetStackLabelMap(await assignmentsRes.json());
|
try {
|
||||||
} catch {
|
const [labelsRes, assignmentsRes] = await Promise.all([
|
||||||
// Non-critical
|
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<string, StackLabel[]>;
|
||||||
|
}
|
||||||
|
} 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]);
|
}, [isPaid]);
|
||||||
|
|
||||||
const fetchUpdateStatus = useCallback(async () => {
|
const fetchUpdateStatus = useCallback(async () => {
|
||||||
@@ -814,9 +844,21 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchOverview();
|
fetchOverview();
|
||||||
fetchLabels();
|
|
||||||
fetchUpdateStatus();
|
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
|
// Paid tier: auto-refresh every 30s
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -900,14 +942,16 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
// Critical filter
|
// Critical filter
|
||||||
if (prefs.filterCritical) filtered = filtered.filter(isCritical);
|
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) {
|
if (labelFilters.size > 0) {
|
||||||
filtered = filtered.filter(n =>
|
filtered = filtered.filter(n => {
|
||||||
n.stacks?.some(s => {
|
const nodeStackLabels = fleetStackLabelMap[n.id] ?? {};
|
||||||
const sLabels = fleetStackLabelMap[s] || [];
|
return n.stacks?.some(s => {
|
||||||
return sLabels.some(l => labelFilters.has(l.id));
|
const sLabels = nodeStackLabels[s] ?? [];
|
||||||
})
|
return sLabels.some(l => labelFilters.has(labelPaletteKey(l.name, l.color)));
|
||||||
);
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
@@ -1147,17 +1191,17 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
Critical Only
|
Critical Only
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{fleetLabels.length > 0 && (
|
{fleetPalette.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
options={fleetLabels.map(l => ({ value: String(l.id), label: l.name, color: l.color }))}
|
options={fleetPalette.map(p => ({ value: p.key, label: p.name, color: p.color }))}
|
||||||
selected={new Set(Array.from(labelFilters).map(String))}
|
selected={labelFilters}
|
||||||
onSelectionChange={(sel) => setLabelFilters(new Set(Array.from(sel).map(Number)))}
|
onSelectionChange={setLabelFilters}
|
||||||
placeholder="Tags"
|
placeholder="Tags"
|
||||||
renderOption={(option) => (
|
renderOption={(option) => (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<LabelDot color={option.color as StackLabel['color'] ?? 'slate'} />
|
<LabelDot color={option.color as LabelColor ?? 'slate'} />
|
||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1181,7 +1225,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
key={localNode.id}
|
key={localNode.id}
|
||||||
node={localNode}
|
node={localNode}
|
||||||
onNavigate={onNavigateToNode}
|
onNavigate={onNavigateToNode}
|
||||||
labelMap={fleetStackLabelMap}
|
labelMap={fleetStackLabelMap[localNode.id] ?? {}}
|
||||||
updateStatus={updateStatusMap.get(localNode.id)}
|
updateStatus={updateStatusMap.get(localNode.id)}
|
||||||
onUpdate={isPaid ? triggerNodeUpdate : undefined}
|
onUpdate={isPaid ? triggerNodeUpdate : undefined}
|
||||||
updatingNodeId={updatingNodeId}
|
updatingNodeId={updatingNodeId}
|
||||||
@@ -1197,7 +1241,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
key={node.id}
|
key={node.id}
|
||||||
node={node}
|
node={node}
|
||||||
onNavigate={onNavigateToNode}
|
onNavigate={onNavigateToNode}
|
||||||
labelMap={fleetStackLabelMap}
|
labelMap={fleetStackLabelMap[node.id] ?? {}}
|
||||||
updateStatus={updateStatusMap.get(node.id)}
|
updateStatus={updateStatusMap.get(node.id)}
|
||||||
onUpdate={isPaid ? triggerNodeUpdate : undefined}
|
onUpdate={isPaid ? triggerNodeUpdate : undefined}
|
||||||
updatingNodeId={updatingNodeId}
|
updatingNodeId={updatingNodeId}
|
||||||
|
|||||||
@@ -158,11 +158,10 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
|||||||
id: 'labels',
|
id: 'labels',
|
||||||
group: 'advanced',
|
group: 'advanced',
|
||||||
label: 'Labels',
|
label: 'Labels',
|
||||||
description: 'Shared labels for stacks, containers, and nodes.',
|
description: 'Per-node labels for stacks and containers.',
|
||||||
keywords: ['labels', 'tags', 'palette', 'organisation'],
|
keywords: ['labels', 'tags', 'palette', 'organisation'],
|
||||||
tier: 'skipper',
|
tier: 'skipper',
|
||||||
scope: 'global',
|
scope: 'node',
|
||||||
hiddenOnRemote: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'security',
|
id: 'security',
|
||||||
|
|||||||
Reference in New Issue
Block a user