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