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
+7 -80
View File
@@ -11,11 +11,13 @@ import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet';
import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog';
import { isCritical, getNodeCpu, getNodeMem, getNodeDisk } from './FleetView/nodeUtils';
import { OverviewTab } from './FleetView/OverviewTab';
import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './FleetView/types';
import type { FleetNode, NodeUpdateStatus, ViewMode } from './FleetView/types';
import { useFleetPreferences } from './FleetView/hooks/useFleetPreferences';
import { useFleetLabels, labelPaletteKey } from './FleetView/hooks/useFleetLabels';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { springs } from '@/lib/motion';
import { apiFetch, fetchForNode } from '@/lib/api';
import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { AdmiralGate } from './AdmiralGate';
import FleetSnapshots from './FleetSnapshots';
@@ -24,25 +26,6 @@ import { FleetSoonPlaceholder, SoonBadge } from './fleet/FleetSoonPlaceholder';
import { RoutingTab } from './fleet/RoutingTab';
import { DeploymentsTab } from './blueprints/DeploymentsTab';
import { toast } from '@/components/ui/toast-store';
import { type Label as StackLabel, type LabelColor } from './label-types';
function labelPaletteKey(name: string, color: LabelColor): string {
return `${name.trim().toLowerCase()}|${color}`;
}
const PREFS_KEY = 'sencho-fleet-preferences';
function loadPreferences(): FleetPreferences {
try {
const stored = localStorage.getItem(PREFS_KEY);
if (stored) return JSON.parse(stored) as FleetPreferences;
} catch { /* use defaults */ }
return { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
}
function savePreferences(prefs: FleetPreferences) {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
}
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
@@ -55,10 +38,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const [searchQuery, setSearchQuery] = useState('');
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [prefs, setPrefs] = useState<FleetPreferences>(loadPreferences);
const [fleetPalette, setFleetPalette] = useState<FleetPaletteEntry[]>([]);
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<number, Record<string, StackLabel[]>>>({});
const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set());
const { isPaid, license } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral';
const experimental = useExperimental();
@@ -72,13 +51,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const updateStatusesRef = useRef(updateStatuses);
updateStatusesRef.current = updateStatuses;
const updatePrefs = useCallback((update: Partial<FleetPreferences>) => {
setPrefs(prev => {
const next = { ...prev, ...update };
savePreferences(next);
return next;
});
}, []);
const { prefs, updatePrefs } = useFleetPreferences();
const { fleetPalette, fleetStackLabelMap, labelFilters, setLabelFilters } = useFleetLabels({ isPaid, nodes });
const fetchOverview = useCallback(async (showRefresh = false) => {
if (showRefresh) setRefreshing(true);
@@ -96,40 +70,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
}
}, []);
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]);
const fetchUpdateStatus = useCallback(async () => {
if (!isPaid) return;
try {
@@ -239,19 +179,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
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(() => {
if (!isPaid) return;
@@ -385,7 +312,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const clearFilters = useCallback(() => {
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
setLabelFilters(new Set());
}, [updatePrefs]);
}, [updatePrefs, setLabelFilters]);
const allNodes = useMemo(
() => (localNode ? [localNode, ...remoteNodes] : remoteNodes),
[localNode, remoteNodes]
@@ -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 };
}