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:
Anso
2026-05-15 08:59:52 -04:00
committed by GitHub
parent 9e4b969dfb
commit 2e82eb44fe
11 changed files with 781 additions and 27 deletions
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTopologyPreferences } from '../useTopologyPreferences';
const PREFS_KEY = 'sencho-topology-preferences';
describe('useTopologyPreferences', () => {
beforeEach(() => {
window.localStorage.clear();
});
it('defaults to hub mode with no positions when storage is empty', () => {
const { result } = renderHook(() => useTopologyPreferences());
expect(result.current.prefs.mode).toBe('hub');
expect(result.current.prefs.positions).toEqual({});
});
it('setMode persists to localStorage', () => {
const { result } = renderHook(() => useTopologyPreferences());
act(() => result.current.setMode('grouped'));
expect(result.current.prefs.mode).toBe('grouped');
const stored = JSON.parse(window.localStorage.getItem(PREFS_KEY)!);
expect(stored.mode).toBe('grouped');
});
it('setPositions persists positions and survives a fresh render', () => {
const positions = { '7': { x: 12, y: 34 }, '8': { x: 56, y: 78 } };
const first = renderHook(() => useTopologyPreferences());
act(() => first.result.current.setPositions(positions));
expect(first.result.current.prefs.positions).toEqual(positions);
const second = renderHook(() => useTopologyPreferences());
expect(second.result.current.prefs.positions).toEqual(positions);
});
it('falls back to defaults when stored JSON is corrupt', () => {
window.localStorage.setItem(PREFS_KEY, '{not-json');
const { result } = renderHook(() => useTopologyPreferences());
expect(result.current.prefs.mode).toBe('hub');
expect(result.current.prefs.positions).toEqual({});
});
it('rejects an invalid mode and uses the default instead', () => {
window.localStorage.setItem(
PREFS_KEY,
JSON.stringify({ mode: 'something-else', positions: {} }),
);
const { result } = renderHook(() => useTopologyPreferences());
expect(result.current.prefs.mode).toBe('hub');
});
it('rejects malformed positions and uses an empty object instead', () => {
window.localStorage.setItem(
PREFS_KEY,
JSON.stringify({ mode: 'free', positions: { '1': 'oops' } }),
);
const { result } = renderHook(() => useTopologyPreferences());
expect(result.current.prefs.mode).toBe('free');
expect(result.current.prefs.positions).toEqual({});
});
it('updatePositions applies an updater function', () => {
const { result } = renderHook(() => useTopologyPreferences());
act(() => result.current.setPositions({ '1': { x: 0, y: 0 } }));
act(() => result.current.updatePositions(prev => ({ ...prev, '2': { x: 1, y: 2 } })));
expect(result.current.prefs.positions).toEqual({
'1': { x: 0, y: 0 },
'2': { x: 1, y: 2 },
});
});
});
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useState } from 'react';
import type { LayoutMode, SavedPositions } from '@/lib/fleet-topology-layout';
const PREFS_KEY = 'sencho-topology-preferences';
export interface TopologyPreferences {
mode: LayoutMode;
positions: SavedPositions;
}
const DEFAULT_PREFS: TopologyPreferences = {
mode: 'hub',
positions: {},
};
function isLayoutMode(value: unknown): value is LayoutMode {
return value === 'hub' || value === 'grouped' || value === 'free';
}
function isSavedPositions(value: unknown): value is SavedPositions {
if (!value || typeof value !== 'object') return false;
for (const v of Object.values(value as Record<string, unknown>)) {
if (!v || typeof v !== 'object') return false;
const p = v as { x?: unknown; y?: unknown };
if (typeof p.x !== 'number' || typeof p.y !== 'number') return false;
}
return true;
}
function loadPreferences(): TopologyPreferences {
try {
const stored = localStorage.getItem(PREFS_KEY);
if (!stored) return { ...DEFAULT_PREFS };
const parsed = JSON.parse(stored) as Partial<TopologyPreferences>;
return {
mode: isLayoutMode(parsed.mode) ? parsed.mode : DEFAULT_PREFS.mode,
positions: isSavedPositions(parsed.positions) ? parsed.positions : {},
};
} catch {
return { ...DEFAULT_PREFS };
}
}
function savePreferences(prefs: TopologyPreferences) {
try {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
} catch {
/* non-fatal: localStorage unavailable or quota exceeded */
}
}
export function useTopologyPreferences() {
const [prefs, setPrefs] = useState<TopologyPreferences>(loadPreferences);
useEffect(() => {
savePreferences(prefs);
}, [prefs]);
const setMode = useCallback((mode: LayoutMode) => {
setPrefs(prev => (prev.mode === mode ? prev : { ...prev, mode }));
}, []);
const setPositions = useCallback((positions: SavedPositions) => {
setPrefs(prev => ({ ...prev, positions }));
}, []);
const updatePositions = useCallback(
(updater: (current: SavedPositions) => SavedPositions) => {
setPrefs(prev => ({ ...prev, positions: updater(prev.positions) }));
},
[],
);
return { prefs, setMode, setPositions, updatePositions };
}