mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
feat(fleet): multi-mode topology with label grouping and persisted positions (#1054)
The Topology view now offers three layouts so the canvas adapts to how the fleet is organised, not the other way around: - Hub: the gateway anchored on the left with remotes radiating right - Grouped (Skipper+): remotes cluster by their primary node label, with the local node in its own cluster and unlabeled remotes in an Unlabeled cluster - Free (Skipper+): drag any node; positions persist per browser via local storage Node cards gain label pills (Skipper+), a cordon banner with reason tooltip, a latency chip for online remotes, and a stale pilot-heartbeat glyph. All fields are sourced from /api/fleet/overview, which already returns cordon, latency, and pilot timestamps; no backend changes required. Community continues to see the single Hub layout (without the toolbar, no gating cues), matching the visibility principle that paid affordances are hidden from lower tiers rather than displayed as locked teasers. The backend /api/node-labels route is already requirePaid, so the data gate is honoured end to end. Includes unit tests for the layout module (hub/grouped/free coverage) and the preferences hook (round-trip plus corrupt-JSON fallback).
This commit is contained in:
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { FleetTopology } from '../fleet/FleetTopology';
|
||||
import { NodeCard } from './NodeCard';
|
||||
import { OverviewToolbar } from './OverviewToolbar';
|
||||
import type { FleetTopologyNode } from '@/lib/fleet-topology-layout';
|
||||
import type { FleetTopologyNode, LayoutMode, SavedPositions } from '@/lib/fleet-topology-layout';
|
||||
import type { Label as StackLabel } from '../label-types';
|
||||
import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types';
|
||||
|
||||
@@ -32,6 +32,11 @@ interface OverviewTabProps {
|
||||
onRetryUpdate?: (nodeId: number) => void;
|
||||
onDismissUpdate?: (nodeId: number) => void;
|
||||
onCordonChange?: () => void;
|
||||
isPaid: boolean;
|
||||
topologyMode: LayoutMode;
|
||||
onTopologyModeChange: (mode: LayoutMode) => void;
|
||||
topologyPositions: SavedPositions;
|
||||
onTopologyPositionsChange: (positions: SavedPositions) => void;
|
||||
}
|
||||
|
||||
export function OverviewTab({
|
||||
@@ -58,6 +63,11 @@ export function OverviewTab({
|
||||
onRetryUpdate,
|
||||
onDismissUpdate,
|
||||
onCordonChange,
|
||||
isPaid,
|
||||
topologyMode,
|
||||
onTopologyModeChange,
|
||||
topologyPositions,
|
||||
onTopologyPositionsChange,
|
||||
}: OverviewTabProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -106,6 +116,11 @@ export function OverviewTab({
|
||||
<FleetTopology
|
||||
nodes={topologyNodes}
|
||||
onNodeClick={(id) => onNavigateToNode(id, '')}
|
||||
isPaid={isPaid}
|
||||
mode={topologyMode}
|
||||
onModeChange={onTopologyModeChange}
|
||||
savedPositions={topologyPositions}
|
||||
onPositionsChange={onTopologyPositionsChange}
|
||||
/>
|
||||
) : processedNodes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 items-start">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useFleetLabels, labelPaletteKey } from './useFleetLabels';
|
||||
import { useNodeLabels } from './useNodeLabels';
|
||||
import { isCritical, getNodeCpu, getNodeMem, getNodeDisk } from '../nodeUtils';
|
||||
import type { FleetNode, ViewMode, FleetPreferences, NodeUpdateStatus } from '../types';
|
||||
|
||||
@@ -34,6 +35,7 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }:
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const { fleetPalette, fleetStackLabelMap } = useFleetLabels({ isPaid, nodes });
|
||||
const { labelsByNodeId, distinctLabels, isAvailable: nodeLabelsAvailable } = useNodeLabels({ isPaid, nodes });
|
||||
|
||||
const fetchOverview = useCallback(async (showRefresh = false) => {
|
||||
abortRef.current?.abort();
|
||||
@@ -164,8 +166,14 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }:
|
||||
stackCount: n.stacks?.length ?? 0,
|
||||
runningCount: n.stats?.active ?? 0,
|
||||
critical: n.status === 'online' && isCritical(n),
|
||||
labels: labelsByNodeId[n.id] ?? [],
|
||||
cordoned: n.cordoned,
|
||||
cordonedReason: n.cordoned_reason,
|
||||
latencyMs: n.latency_ms ?? null,
|
||||
pilotLastSeen: n.pilot_last_seen ?? null,
|
||||
nodeMode: n.mode ?? null,
|
||||
})),
|
||||
[processedNodes]
|
||||
[processedNodes, labelsByNodeId]
|
||||
);
|
||||
const allNodes = useMemo(
|
||||
() => (localNode ? [localNode, ...remoteNodes] : remoteNodes),
|
||||
@@ -207,6 +215,9 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }:
|
||||
updateStatusMap,
|
||||
fleetPalette,
|
||||
fleetStackLabelMap,
|
||||
labelsByNodeId,
|
||||
distinctNodeLabels: distinctLabels,
|
||||
nodeLabelsAvailable,
|
||||
activeFilterCount,
|
||||
clearFilters,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { FleetNode } from '../types';
|
||||
|
||||
export interface UseNodeLabelsResult {
|
||||
labelsByNodeId: Record<number, string[]>;
|
||||
distinctLabels: string[];
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
interface UseNodeLabelsOptions {
|
||||
isPaid: boolean;
|
||||
nodes: FleetNode[];
|
||||
}
|
||||
|
||||
export function useNodeLabels({ isPaid, nodes }: UseNodeLabelsOptions): UseNodeLabelsResult {
|
||||
const [labelsByNodeId, setLabelsByNodeId] = useState<Record<number, string[]>>({});
|
||||
|
||||
const fetchLabels = useCallback(async () => {
|
||||
if (!isPaid) {
|
||||
setLabelsByNodeId({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/node-labels', {
|
||||
localOnly: true,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setLabelsByNodeId({});
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as Record<string, string[]>;
|
||||
// Backend returns numeric keys as strings in JSON; normalize.
|
||||
const map: Record<number, string[]> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
const id = Number(k);
|
||||
if (!Number.isNaN(id) && Array.isArray(v)) map[id] = v;
|
||||
}
|
||||
setLabelsByNodeId(map);
|
||||
} catch {
|
||||
setLabelsByNodeId({});
|
||||
}
|
||||
}, [isPaid]);
|
||||
|
||||
// Refetch only when the set of node ids actually changes, not on every poll
|
||||
// (poll mints a fresh nodes array reference).
|
||||
const nodeIdKey = useMemo(
|
||||
() => nodes.map(n => n.id).sort((a, b) => a - b).join(','),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaid) {
|
||||
setLabelsByNodeId({});
|
||||
return;
|
||||
}
|
||||
void fetchLabels();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPaid, nodeIdKey, fetchLabels]);
|
||||
|
||||
const distinctLabels = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const list of Object.values(labelsByNodeId)) {
|
||||
for (const l of list) set.add(l);
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [labelsByNodeId]);
|
||||
|
||||
return { labelsByNodeId, distinctLabels, isAvailable: isPaid };
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface FleetNode {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
mode?: string;
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
stats: FleetNodeStats | null;
|
||||
systemStats: FleetNodeSystemStats | null;
|
||||
@@ -25,6 +26,9 @@ export interface FleetNode {
|
||||
cordoned: boolean;
|
||||
cordoned_at: number | null;
|
||||
cordoned_reason: string | null;
|
||||
latency_ms?: number;
|
||||
last_successful_contact?: number | null;
|
||||
pilot_last_seen?: number | null;
|
||||
}
|
||||
|
||||
export interface NodeUpdateStatus {
|
||||
|
||||
Reference in New Issue
Block a user