refactor(frontend): extract useFleetPreferences + useFleetLabels from FleetView (F5-6) (#920)

* refactor(frontend): extract useFleetPreferences + useFleetLabels from FleetView (F5-6)

Move localStorage preferences and label palette/assignment fetching out of the
FleetView shell into dedicated hooks under FleetView/hooks/:

- useFleetPreferences: wraps PREFS_KEY, loadPreferences, savePreferences, and
  the prefs useState + updatePrefs callback. Defaults are merged on load so stale
  stored values cannot produce missing keys. Save is a side-effect-free useEffect
  rather than a setState updater call, consistent with React purity contract.
- useFleetLabels: wraps fleetPalette, fleetStackLabelMap, labelFilters state,
  fetchLabelsForNodes callback, and the onlineNodeKey-gated fetch effect. The
  onlineNodeKey derivation is memoized. labelPaletteKey is exported for the
  shell processedNodes useMemo until F5-8 absorbs it.

Shell useState: 17 to 13. useEffect: 5 to 4. useCallback: 10 to 8.

* fix(frontend): add comments to empty catch blocks in useFleetPreferences

Empty catch blocks trigger the no-empty lint rule. Add explanatory comments
to both catch sites to satisfy the rule while keeping the intent clear.
This commit is contained in:
Anso
2026-05-04 19:28:04 -04:00
committed by GitHub
parent 4c171b0643
commit f74322021b
3 changed files with 115 additions and 80 deletions
@@ -0,0 +1,72 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { fetchForNode } from '@/lib/api';
import type { FleetNode, FleetPaletteEntry } from '../types';
import type { Label as StackLabel, LabelColor } from '../../label-types';
export function labelPaletteKey(name: string, color: LabelColor): string {
return `${name.trim().toLowerCase()}|${color}`;
}
interface UseFleetLabelsOptions {
isPaid: boolean;
nodes: FleetNode[];
}
export function useFleetLabels({ isPaid, nodes }: UseFleetLabelsOptions) {
const [fleetPalette, setFleetPalette] = useState<FleetPaletteEntry[]>([]);
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<number, Record<string, StackLabel[]>>>({});
const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set());
const fetchLabelsForNodes = useCallback(async (fleetNodes: FleetNode[]) => {
if (!isPaid || fleetNodes.length === 0) return;
const paletteMap = new Map<string, FleetPaletteEntry>();
const stackLabelMap: Record<number, Record<string, StackLabel[]>> = {};
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<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]);
// Refetch labels only when the set of online nodes actually changes,
// not on every fetchOverview tick (which mints a new nodes ref).
const onlineNodeKey = useMemo(
() => nodes
.filter(n => n.status === 'online')
.map(n => n.id)
.sort((a, b) => a - b)
.join(','),
[nodes]
);
useEffect(() => {
if (!isPaid || nodes.length === 0) return;
fetchLabelsForNodes(nodes);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPaid, onlineNodeKey, fetchLabelsForNodes]);
return { fleetPalette, fleetStackLabelMap, labelFilters, setLabelFilters };
}
@@ -0,0 +1,36 @@
import { useState, useEffect, useCallback } from 'react';
import type { FleetPreferences } from '../types';
const PREFS_KEY = 'sencho-fleet-preferences';
const DEFAULT_PREFS: FleetPreferences = {
sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false,
};
function loadPreferences(): FleetPreferences {
try {
const stored = localStorage.getItem(PREFS_KEY);
if (stored) return { ...DEFAULT_PREFS, ...(JSON.parse(stored) as Partial<FleetPreferences>) };
} catch { /* corrupted or missing — use defaults */ }
return { ...DEFAULT_PREFS };
}
function savePreferences(prefs: FleetPreferences) {
try {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
} catch { /* non-fatal: localStorage unavailable or quota exceeded */ }
}
export function useFleetPreferences() {
const [prefs, setPrefs] = useState<FleetPreferences>(loadPreferences);
const updatePrefs = useCallback((update: Partial<FleetPreferences>) => {
setPrefs(prev => ({ ...prev, ...update }));
}, []);
useEffect(() => {
savePreferences(prefs);
}, [prefs]);
return { prefs, updatePrefs };
}