mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 04:38:59 +00:00
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user