refactor(ui): make the two job pollers declarative, and finish the container tabs

Plan 76 milestone E2, continuing 89c3896e. Ratchet 22 sites / 15 files -> 16 / 12.

DoctorPanel's fleet sweep and ScannerTab's malware scan were imperative
pollers: a function started an interval, and the interval cleared itself once
the job reached a terminal status, with a second clearInterval in an unmount
cleanup to catch the case where it never did. Both now hold the job id in state
and let usePolling own the schedule —

    const pollSweep = useCallback((jobId) => setSweepJobId(jobId), []);
    usePolling(tick, SWEEP_POLL_MS, { enabled: Boolean(sweepJobId) });

which is what makes the visibility pause and the in-flight guard apply to them
too. A job poller is the shape that most wants both: it runs while someone
watches a spinner, and its endpoint is doing real work.

Also ContainersTab's stats poll and log tail, and OnboardingWizard's status
poll (its 1s elapsed-time clock stays a plain interval — nothing to overlap).

Two `useRef`s and one `clearInterval` in an unmount cleanup went with them.

Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Opus 5 committed 2026-08-18 21:51:30 -04:00
1 parent 89c3896ec4
commit 0bfcf69c0b
5 files changed
+86 -85

No files matched your search

@@ -45,10 +45,7 @@ const LEGACY_CLIPBOARD = new Map();
const LEGACY_POLLERS = new Map(Object.entries({ const LEGACY_POLLERS = new Map(Object.entries({
'components/dashboard/widgets/renderers.jsx': 1, 'components/dashboard/widgets/renderers.jsx': 1,
'components/deploy-console/SuccessBanner.jsx': 1, 'components/deploy-console/SuccessBanner.jsx': 1,
'components/docker/ContainersTab.jsx': 2, 'components/server/OnboardingWizard.jsx': 1,
'components/monitoring/DoctorPanel.jsx': 1,
'components/security/ScannerTab.jsx': 2,
'components/server/OnboardingWizard.jsx': 2,
'hooks/useContainerStatus.js': 1, 'hooks/useContainerStatus.js': 1,
'hooks/useDeployJobStream.js': 1, 'hooks/useDeployJobStream.js': 1,
'hooks/useMetrics.js': 1, 'hooks/useMetrics.js': 1,
@@ -41,6 +41,13 @@ import {
import { ContainerResourceBars } from './dockerShared'; import { ContainerResourceBars } from './dockerShared';
import { copyToClipboard } from '@/utils/clipboard'; import { copyToClipboard } from '@/utils/clipboard';
import { downloadBlob } from '@/utils/downloadBlob'; 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 // Action Buttons
export const RunContainerButton = () => { export const RunContainerButton = () => {
@@ -284,15 +291,11 @@ const ContainersTab = ({ onStatsChange }) => {
})); }));
}, [fetchContainerStats, isRemote]); }, [fetchContainerStats, isRemote]);
useEffect(() => { usePolling(
if (loading || containers.length === 0) return undefined; () => refreshContainerStats(containers, statsRequestSeq.current),
STATS_REFRESH_MS,
const timer = window.setInterval(() => { { enabled: !loading && containers.length > 0, immediate: false },
refreshContainerStats(containers, statsRequestSeq.current); );
}, 10000);
return () => window.clearInterval(timer);
}, [containers, loading, refreshContainerStats]);
async function loadContainers() { async function loadContainers() {
setLoading(true); setLoading(true);
@@ -1225,18 +1228,15 @@ const ContainerLogsModal = ({ container, onClose }) => {
const [showLineNumbers, setShowLineNumbers] = useState(true); const [showLineNumbers, setShowLineNumbers] = useState(true);
const [wrapLines, setWrapLines] = useState(true); const [wrapLines, setWrapLines] = useState(true);
const contentRef = useRef(null); const contentRef = useRef(null);
const intervalRef = useRef(null);
useEffect(() => { useEffect(() => {
loadLogs(); loadLogs();
}, [container, tail]); // eslint-disable-line }, [container, tail]); // eslint-disable-line
useEffect(() => { usePolling(() => loadLogs(false), LOG_TAIL_MS, {
if (autoRefresh) { enabled: autoRefresh,
intervalRef.current = setInterval(() => loadLogs(false), 3000); immediate: false,
} });
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [autoRefresh, tail]); // eslint-disable-line
async function loadLogs(showSpinner = true) { async function loadLogs(showSpinner = true) {
if (showSpinner) setLoading(true); if (showSpinner) setLoading(true);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../../services/api'; import api from '../../services/api';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
@@ -7,6 +7,7 @@ import Modal from '@/components/Modal';
import { Pill } from '@/components/ds'; import { Pill } from '@/components/ds';
import EmptyState from '../EmptyState'; import EmptyState from '../EmptyState';
import { ChevronDown, ChevronRight, Server, Stethoscope, Wrench } from 'lucide-react'; 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' }; const STATUS_TONE = { ok: 'green', warn: 'amber', fail: 'red', error: 'red' };
@@ -73,7 +74,7 @@ const DoctorPanel = () => {
const [fleet, setFleet] = useState(null); const [fleet, setFleet] = useState(null);
const [fleetLoading, setFleetLoading] = useState(true); const [fleetLoading, setFleetLoading] = useState(true);
const [sweeping, setSweeping] = useState(false); const [sweeping, setSweeping] = useState(false);
const sweepPollRef = useRef(null); const [sweepJobId, setSweepJobId] = useState(null);
const loadFleet = useCallback(async () => { const loadFleet = useCallback(async () => {
try { try {
@@ -94,10 +95,7 @@ const DoctorPanel = () => {
.catch(() => { /* no stored report yet — the empty state covers it */ }) .catch(() => { /* no stored report yet — the empty state covers it */ })
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { if (!cancelled) setLoading(false); });
loadFleet(); loadFleet();
return () => { return () => { cancelled = true; };
cancelled = true;
if (sweepPollRef.current) clearInterval(sweepPollRef.current);
};
}, [loadFleet]); }, [loadFleet]);
const runDiagnosis = async () => { const runDiagnosis = async () => {
@@ -116,27 +114,27 @@ const DoctorPanel = () => {
} }
}; };
const pollSweep = useCallback((jobId) => { // The job id IS the "are we polling" state, so the poll is declarative and
if (sweepPollRef.current) clearInterval(sweepPollRef.current); // the hook owns starting, stopping, and not overlapping ticks.
sweepPollRef.current = setInterval(async () => { const pollSweep = useCallback((jobId) => setSweepJobId(jobId), []);
try {
const res = await api.getJob(jobId); usePolling(async () => {
const job = res.job || res; try {
if (!TERMINAL_JOB_STATUSES.includes(job.status)) return; const res = await api.getJob(sweepJobId);
clearInterval(sweepPollRef.current); const job = res.job || res;
sweepPollRef.current = null; if (!TERMINAL_JOB_STATUSES.includes(job.status)) return;
setSweeping(false); setSweepJobId(null);
if (job.status === 'succeeded') { setSweeping(false);
await loadFleet(); if (job.status === 'succeeded') {
toast.success('Fleet sweep finished'); await loadFleet();
} else { toast.success('Fleet sweep finished');
toast.error(job.error_message || 'Fleet sweep did not finish'); } else {
} toast.error(job.error_message || 'Fleet sweep did not finish');
} catch {
// Transient poll failure — the next tick retries.
} }
}, SWEEP_POLL_MS); } catch {
}, [loadFleet, toast]); // Transient poll failure — the next tick retries.
}
}, SWEEP_POLL_MS, { enabled: Boolean(sweepJobId), immediate: false });
const runSweep = async () => { const runSweep = async () => {
try { try {
+36 -33
View File
@@ -12,6 +12,11 @@ import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import { Zap, Radar, FolderSearch, Download, Box, FileCode2, Trash2, Search } from 'lucide-react'; import { Zap, Radar, FolderSearch, Download, Box, FileCode2, Trash2, Search } from 'lucide-react';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import { useConfirm } from '@/hooks/useConfirm'; 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 = { const SEVERITY_TONE = {
critical: 'red', critical: 'red',
@@ -135,7 +140,7 @@ const ScannerTab = () => {
const [apps, setApps] = useState([]); const [apps, setApps] = useState([]);
const [selectedApp, setSelectedApp] = useState(''); const [selectedApp, setSelectedApp] = useState('');
const [scanJob, setScanJob] = useState(null); // { id, status, result } 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 // Findings (YARA + ClamAV) from the last completed job/scan
const [findings, setFindings] = useState([]); const [findings, setFindings] = useState([]);
@@ -155,13 +160,10 @@ const ScannerTab = () => {
loadScanStatus(); loadScanStatus();
loadHistory(); loadHistory();
loadApps(); 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() { async function loadScanStatus() {
try { try {
const data = await api.getScanStatus(); 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) { function pollJob(jobId) {
if (jobPollRef.current) clearInterval(jobPollRef.current); setScanJobId(jobId);
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);
} }
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() { async function handleScanApp() {
if (!selectedApp) return; if (!selectedApp) return;
setScanning(true); setScanning(true);
@@ -4,6 +4,11 @@ import { useToast } from '../../contexts/ToastContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Pill } from '../ds'; import { Pill } from '../ds';
import { CheckCircle2, Loader2, Circle, XCircle, RotateCw, Clock, ChevronDown } from 'lucide-react'; 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 // 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 // `status.states`, but we keep a labeled copy so the wizard renders before the
@@ -109,12 +114,10 @@ const OnboardingWizard = ({ serverId, initialState, onStateChange }) => {
}, [loadStatus]); }, [loadStatus]);
// Poll while onboarding is in flight; stop once terminal. // Poll while onboarding is in flight; stop once terminal.
useEffect(() => { usePolling(loadStatus, ONBOARDING_POLL_MS, {
if (!status) return undefined; enabled: Boolean(status) && !status.is_terminal,
if (status.is_terminal) return undefined; immediate: false,
const interval = setInterval(loadStatus, 3000); });
return () => clearInterval(interval);
}, [status, loadStatus]);
// Keep the elapsed timer ticking only while non-terminal. // Keep the elapsed timer ticking only while non-terminal.
useEffect(() => { useEffect(() => {