mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 15:47:50 +09:00
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:
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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user