mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user