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({
'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,
@@ -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);
@@ -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 {
+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 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);
@@ -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(() => {