diff --git a/frontend/scripts/check-frontend-boundaries.mjs b/frontend/scripts/check-frontend-boundaries.mjs index f808a491..415d749e 100644 --- a/frontend/scripts/check-frontend-boundaries.mjs +++ b/frontend/scripts/check-frontend-boundaries.mjs @@ -45,10 +45,7 @@ const LEGACY_CLIPBOARD = new Map(); const LEGACY_POLLERS = new Map(Object.entries({ 'components/dashboard/widgets/renderers.jsx': 1, 'components/deploy-console/SuccessBanner.jsx': 1, - 'components/docker/ContainersTab.jsx': 2, - 'components/monitoring/DoctorPanel.jsx': 1, - 'components/security/ScannerTab.jsx': 2, - 'components/server/OnboardingWizard.jsx': 2, + 'components/server/OnboardingWizard.jsx': 1, 'hooks/useContainerStatus.js': 1, 'hooks/useDeployJobStream.js': 1, 'hooks/useMetrics.js': 1, diff --git a/frontend/src/components/docker/ContainersTab.jsx b/frontend/src/components/docker/ContainersTab.jsx index 6be3ffdb..382dc126 100644 --- a/frontend/src/components/docker/ContainersTab.jsx +++ b/frontend/src/components/docker/ContainersTab.jsx @@ -41,6 +41,13 @@ import { import { ContainerResourceBars } from './dockerShared'; import { copyToClipboard } from '@/utils/clipboard'; import { downloadBlob } from '@/utils/downloadBlob'; +import { usePolling } from '@/hooks/usePolling'; + +// Container stats cadence. +const STATS_REFRESH_MS = 10000; +// Container log tail cadence while auto-refresh is on. +const LOG_TAIL_MS = 3000; + // Action Buttons export const RunContainerButton = () => { @@ -284,15 +291,11 @@ const ContainersTab = ({ onStatsChange }) => { })); }, [fetchContainerStats, isRemote]); - useEffect(() => { - if (loading || containers.length === 0) return undefined; - - const timer = window.setInterval(() => { - refreshContainerStats(containers, statsRequestSeq.current); - }, 10000); - - return () => window.clearInterval(timer); - }, [containers, loading, refreshContainerStats]); + usePolling( + () => refreshContainerStats(containers, statsRequestSeq.current), + STATS_REFRESH_MS, + { enabled: !loading && containers.length > 0, immediate: false }, + ); async function loadContainers() { setLoading(true); @@ -1225,18 +1228,15 @@ const ContainerLogsModal = ({ container, onClose }) => { const [showLineNumbers, setShowLineNumbers] = useState(true); const [wrapLines, setWrapLines] = useState(true); const contentRef = useRef(null); - const intervalRef = useRef(null); useEffect(() => { loadLogs(); }, [container, tail]); // eslint-disable-line - useEffect(() => { - if (autoRefresh) { - intervalRef.current = setInterval(() => loadLogs(false), 3000); - } - return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [autoRefresh, tail]); // eslint-disable-line + usePolling(() => loadLogs(false), LOG_TAIL_MS, { + enabled: autoRefresh, + immediate: false, + }); async function loadLogs(showSpinner = true) { if (showSpinner) setLoading(true); diff --git a/frontend/src/components/monitoring/DoctorPanel.jsx b/frontend/src/components/monitoring/DoctorPanel.jsx index 6e124afc..bd8da29b 100644 --- a/frontend/src/components/monitoring/DoctorPanel.jsx +++ b/frontend/src/components/monitoring/DoctorPanel.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import api from '../../services/api'; import { useToast } from '../../contexts/ToastContext'; @@ -7,6 +7,7 @@ import Modal from '@/components/Modal'; import { Pill } from '@/components/ds'; import EmptyState from '../EmptyState'; import { ChevronDown, ChevronRight, Server, Stethoscope, Wrench } from 'lucide-react'; +import { usePolling } from '@/hooks/usePolling'; const STATUS_TONE = { ok: 'green', warn: 'amber', fail: 'red', error: 'red' }; @@ -73,7 +74,7 @@ const DoctorPanel = () => { const [fleet, setFleet] = useState(null); const [fleetLoading, setFleetLoading] = useState(true); const [sweeping, setSweeping] = useState(false); - const sweepPollRef = useRef(null); + const [sweepJobId, setSweepJobId] = useState(null); const loadFleet = useCallback(async () => { try { @@ -94,10 +95,7 @@ const DoctorPanel = () => { .catch(() => { /* no stored report yet — the empty state covers it */ }) .finally(() => { if (!cancelled) setLoading(false); }); loadFleet(); - return () => { - cancelled = true; - if (sweepPollRef.current) clearInterval(sweepPollRef.current); - }; + return () => { cancelled = true; }; }, [loadFleet]); const runDiagnosis = async () => { @@ -116,27 +114,27 @@ const DoctorPanel = () => { } }; - const pollSweep = useCallback((jobId) => { - if (sweepPollRef.current) clearInterval(sweepPollRef.current); - sweepPollRef.current = setInterval(async () => { - try { - const res = await api.getJob(jobId); - const job = res.job || res; - if (!TERMINAL_JOB_STATUSES.includes(job.status)) return; - clearInterval(sweepPollRef.current); - sweepPollRef.current = null; - setSweeping(false); - if (job.status === 'succeeded') { - await loadFleet(); - toast.success('Fleet sweep finished'); - } else { - toast.error(job.error_message || 'Fleet sweep did not finish'); - } - } catch { - // Transient poll failure — the next tick retries. + // The job id IS the "are we polling" state, so the poll is declarative and + // the hook owns starting, stopping, and not overlapping ticks. + const pollSweep = useCallback((jobId) => setSweepJobId(jobId), []); + + usePolling(async () => { + try { + const res = await api.getJob(sweepJobId); + const job = res.job || res; + if (!TERMINAL_JOB_STATUSES.includes(job.status)) return; + setSweepJobId(null); + setSweeping(false); + if (job.status === 'succeeded') { + await loadFleet(); + toast.success('Fleet sweep finished'); + } else { + toast.error(job.error_message || 'Fleet sweep did not finish'); } - }, SWEEP_POLL_MS); - }, [loadFleet, toast]); + } catch { + // Transient poll failure — the next tick retries. + } + }, SWEEP_POLL_MS, { enabled: Boolean(sweepJobId), immediate: false }); const runSweep = async () => { try { diff --git a/frontend/src/components/security/ScannerTab.jsx b/frontend/src/components/security/ScannerTab.jsx index 43b7676c..35423b4c 100644 --- a/frontend/src/components/security/ScannerTab.jsx +++ b/frontend/src/components/security/ScannerTab.jsx @@ -12,6 +12,11 @@ import { useColumnVisibility } from '@/hooks/useColumnVisibility'; import { Zap, Radar, FolderSearch, Download, Box, FileCode2, Trash2, Search } from 'lucide-react'; import EmptyState from '@/components/EmptyState'; import { useConfirm } from '@/hooks/useConfirm'; +import { usePolling } from '@/hooks/usePolling'; + +// Scan status cadence, and the faster cadence while a scan job runs. +const SCAN_STATUS_POLL_MS = 5000; +const SCAN_JOB_POLL_MS = 3000; const SEVERITY_TONE = { critical: 'red', @@ -135,7 +140,7 @@ const ScannerTab = () => { const [apps, setApps] = useState([]); const [selectedApp, setSelectedApp] = useState(''); const [scanJob, setScanJob] = useState(null); // { id, status, result } - const jobPollRef = useRef(null); + const [scanJobId, setScanJobId] = useState(null); // Findings (YARA + ClamAV) from the last completed job/scan const [findings, setFindings] = useState([]); @@ -155,13 +160,10 @@ const ScannerTab = () => { loadScanStatus(); loadHistory(); loadApps(); - const interval = setInterval(loadScanStatus, 5000); - return () => { - clearInterval(interval); - if (jobPollRef.current) clearInterval(jobPollRef.current); - }; }, []); + usePolling(loadScanStatus, SCAN_STATUS_POLL_MS, { immediate: false }); + async function loadScanStatus() { try { const data = await api.getScanStatus(); @@ -201,36 +203,37 @@ const ScannerTab = () => { } } + // The job id IS the "are we polling" state; the hook owns the schedule, + // so this only has to say which job to watch. function pollJob(jobId) { - if (jobPollRef.current) clearInterval(jobPollRef.current); - jobPollRef.current = setInterval(async () => { - try { - const job = await api.getJob(jobId); - setScanJob(job); - if (['succeeded', 'failed', 'cancelled'].includes(job.status)) { - clearInterval(jobPollRef.current); - jobPollRef.current = null; - if (job.status === 'succeeded' && job.result?.findings) { - setFindings(job.result.findings); - const n = job.result.findings.length; - setMessage({ - type: n > 0 ? 'error' : 'success', - text: n > 0 - ? `Scan finished: ${n} finding(s) — review below` - : 'Scan finished: no threats found' - }); - } else if (job.status === 'failed') { - setMessage({ type: 'error', text: job.error_message || 'Scan job failed' }); - } - loadScanStatus(); - loadHistory(); - } - } catch (err) { - console.error('Failed to poll scan job:', err); - } - }, 3000); + setScanJobId(jobId); } + usePolling(async () => { + try { + const job = await api.getJob(scanJobId); + setScanJob(job); + if (!['succeeded', 'failed', 'cancelled'].includes(job.status)) return; + setScanJobId(null); + if (job.status === 'succeeded' && job.result?.findings) { + setFindings(job.result.findings); + const n = job.result.findings.length; + setMessage({ + type: n > 0 ? 'error' : 'success', + text: n > 0 + ? `Scan finished: ${n} finding(s) — review below` + : 'Scan finished: no threats found', + }); + } else if (job.status === 'failed') { + setMessage({ type: 'error', text: job.error_message || 'Scan job failed' }); + } + loadScanStatus(); + loadHistory(); + } catch (err) { + console.error('Failed to poll scan job:', err); + } + }, SCAN_JOB_POLL_MS, { enabled: Boolean(scanJobId), immediate: false }); + async function handleScanApp() { if (!selectedApp) return; setScanning(true); diff --git a/frontend/src/components/server/OnboardingWizard.jsx b/frontend/src/components/server/OnboardingWizard.jsx index 9032c79f..f0f9e8e8 100644 --- a/frontend/src/components/server/OnboardingWizard.jsx +++ b/frontend/src/components/server/OnboardingWizard.jsx @@ -4,6 +4,11 @@ import { useToast } from '../../contexts/ToastContext'; import { Button } from '@/components/ui/button'; import { Pill } from '../ds'; import { CheckCircle2, Loader2, Circle, XCircle, RotateCw, Clock, ChevronDown } from 'lucide-react'; +import { usePolling } from '@/hooks/usePolling'; + +// Onboarding status cadence while non-terminal. +const ONBOARDING_POLL_MS = 3000; + // The canonical ordered lifecycle steps. The backend returns the same list in // `status.states`, but we keep a labeled copy so the wizard renders before the @@ -109,12 +114,10 @@ const OnboardingWizard = ({ serverId, initialState, onStateChange }) => { }, [loadStatus]); // Poll while onboarding is in flight; stop once terminal. - useEffect(() => { - if (!status) return undefined; - if (status.is_terminal) return undefined; - const interval = setInterval(loadStatus, 3000); - return () => clearInterval(interval); - }, [status, loadStatus]); + usePolling(loadStatus, ONBOARDING_POLL_MS, { + enabled: Boolean(status) && !status.is_terminal, + immediate: false, + }); // Keep the elapsed timer ticking only while non-terminal. useEffect(() => {