refactor(frontend): extract useFleetUpdateStatus + useFleetPolling from FleetView (F5-7) (#921)

Move the update workflow state machine and the polling driver out of the
FleetView shell into two dedicated hooks under FleetView/hooks/. No
behavior change.

useFleetUpdateStatus owns updateStatuses + updatingNodeId + the four
modal/dialog/reconnecting state slots, the synchronously-held
updateStatusesRef, and every callback that touches the update workflow
(fetchUpdateStatus, triggerNodeUpdate, confirmLocalUpdate,
triggerUpdateAll, dismissNodeUpdate, retryNodeUpdate). The inline four-
line "Check Updates" handler collapses into a single checkUpdates()
callback returned from the hook.

useFleetPolling is a pure side-effect hook that owns the initial-mount
fetch, the paid-tier 30s overview + 120s update-status interval pair,
and the 5s fast-poll accelerator gated on hasUpdatingRef. The polling
hook does not know about update semantics; the consumer passes in
updateStatuses and the fetch callbacks.

Shell drops from 523 to 385 LOC. useState calls drop from 13 to 6,
useEffect from 4 to 0, useCallback from 8 to 2, useRef from 2 to 0.

Tested manually in browser: fleet view loads, masthead populates, Check
Updates opens the sheet and fetches statuses, Refresh fires
fetch-overview, no console errors from the refactor (existing
unreachable-remote-node WebSocket failures are unrelated). Dev servers
killed after validation.
This commit is contained in:
Anso
2026-05-04 21:31:15 -04:00
committed by GitHub
parent f74322021b
commit 71678081f0
3 changed files with 230 additions and 170 deletions
@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react';
import type { NodeUpdateStatus } from '../types';
interface UseFleetPollingOptions {
isPaid: boolean;
fetchOverview: () => Promise<void> | void;
fetchUpdateStatus: () => Promise<void> | void;
updateStatuses: NodeUpdateStatus[];
}
export function useFleetPolling({
isPaid,
fetchOverview,
fetchUpdateStatus,
updateStatuses,
}: UseFleetPollingOptions): void {
useEffect(() => {
fetchOverview();
fetchUpdateStatus();
}, [fetchOverview, fetchUpdateStatus]);
// Paid tier: auto-refresh every 30s
useEffect(() => {
if (!isPaid) return;
const overviewInterval = setInterval(fetchOverview, 30000);
const updateInterval = setInterval(fetchUpdateStatus, 120000);
return () => { clearInterval(overviewInterval); clearInterval(updateInterval); };
}, [isPaid, fetchOverview, fetchUpdateStatus]);
// Fast poll (5s) when any node is actively updating. Uses ref to avoid interval thrashing.
const hasUpdatingRef = useRef(false);
useEffect(() => {
hasUpdatingRef.current = updateStatuses.some(s => s.updateStatus === 'updating');
}, [updateStatuses]);
useEffect(() => {
const id = setInterval(() => {
if (hasUpdatingRef.current) {
fetchUpdateStatus();
fetchOverview();
}
}, 5000);
return () => clearInterval(id);
}, [fetchUpdateStatus, fetchOverview]);
}
@@ -0,0 +1,153 @@
import { useState, useCallback, useRef } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import type { NodeUpdateStatus } from '../types';
interface UseFleetUpdateStatusOptions {
isPaid: boolean;
}
export function useFleetUpdateStatus({ isPaid }: UseFleetUpdateStatusOptions) {
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false);
const [preUpdateStartedAt, setPreUpdateStartedAt] = useState<number | null>(null);
const [localUpdateConfirm, setLocalUpdateConfirm] = useState<number | null>(null);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [checkingUpdates, setCheckingUpdates] = useState(false);
// Held synchronously so non-memoised callers (triggerNodeUpdate) read the
// latest snapshot without taking updateStatuses as a dependency.
const updateStatusesRef = useRef(updateStatuses);
updateStatusesRef.current = updateStatuses;
const fetchUpdateStatus = useCallback(async () => {
if (!isPaid) return;
try {
const res = await apiFetch('/fleet/update-status', { localOnly: true });
if (res.ok) {
const data = await res.json();
const next: NodeUpdateStatus[] = data.nodes ?? [];
setUpdateStatuses(prev =>
JSON.stringify(prev) === JSON.stringify(next) ? prev : next
);
}
} catch { /* non-critical */ }
}, [isPaid]);
const triggerNodeUpdate = useCallback(async (nodeId: number) => {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
if (status?.type === 'local') {
setLocalUpdateConfirm(nodeId);
return;
}
setUpdatingNodeId(nodeId);
try {
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true });
if (res.ok) {
toast.success(`Update initiated on ${status?.name ?? 'node'}.`);
fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger update.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
}
}, [fetchUpdateStatus]);
const confirmLocalUpdate = useCallback(async () => {
const nodeId = localUpdateConfirm;
setLocalUpdateConfirm(null);
if (!nodeId) return;
setUpdatingNodeId(nodeId);
try {
// Capture pre-update boot timestamp so the overlay can detect a real restart
// vs a false "online" response from the still-running old process mid-pull.
let bootBefore: number | null = null;
try {
const healthRes = await fetch('/api/health');
if (healthRes.ok) {
const data = await healthRes.json();
if (typeof data?.startedAt === 'number') bootBefore = data.startedAt;
}
} catch { /* fall back to offline-then-online detection */ }
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true });
if (res.ok) {
setPreUpdateStartedAt(bootBefore);
setReconnecting(true);
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger local update.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
}
}, [localUpdateConfirm]);
const triggerUpdateAll = useCallback(async () => {
try {
const res = await apiFetch('/fleet/update-all', { method: 'POST', localOnly: true });
if (res.ok) {
const data = await res.json();
if (data.updating?.length > 0) {
toast.success(`Update initiated on ${data.updating.length} node${data.updating.length > 1 ? 's' : ''}.`);
} else {
toast.success('All nodes are up to date.');
}
fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger fleet update.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
}
}, [fetchUpdateStatus]);
const dismissNodeUpdate = useCallback(async (nodeId: number) => {
try {
await apiFetch(`/fleet/nodes/${nodeId}/update-status`, { method: 'DELETE', localOnly: true });
fetchUpdateStatus();
} catch (error) {
console.error('[Fleet] Failed to dismiss update status:', error);
}
}, [fetchUpdateStatus]);
const retryNodeUpdate = useCallback(async (nodeId: number) => {
triggerNodeUpdate(nodeId);
}, [triggerNodeUpdate]);
const checkUpdates = useCallback(async () => {
setShowUpdateModal(true);
setCheckingUpdates(true);
await fetchUpdateStatus();
setCheckingUpdates(false);
}, [fetchUpdateStatus]);
return {
updateStatuses,
updatingNodeId,
reconnecting,
preUpdateStartedAt,
localUpdateConfirm,
showUpdateModal,
checkingUpdates,
setShowUpdateModal,
setLocalUpdateConfirm,
fetchUpdateStatus,
triggerNodeUpdate,
confirmLocalUpdate,
triggerUpdateAll,
dismissNodeUpdate,
retryNodeUpdate,
checkUpdates,
};
}