diff --git a/ui/src/app/jobs/[jobID]/page.tsx b/ui/src/app/jobs/[jobID]/page.tsx index 7f5c2a8..80a01f3 100644 --- a/ui/src/app/jobs/[jobID]/page.tsx +++ b/ui/src/app/jobs/[jobID]/page.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useState, useEffect, use } from 'react'; +import { useState, use } from 'react'; import { FaChevronLeft } from 'react-icons/fa'; import { MdDashboard, MdImage, MdShowChart, MdCode, MdExtension } from 'react-icons/md'; import { Button } from '@headlessui/react'; import { TopBar, MainContent } from '@/components/layout'; import useJob from '@/hooks/useJob'; +import usePollLoop from '@/hooks/usePollLoop'; import SampleImages, { SampleImagesMenu } from '@/components/SampleImages'; import JobOverview from '@/components/JobOverview'; import { redirect } from 'next/navigation'; @@ -77,18 +78,16 @@ export default function JobPage({ params }: { params: { jobID: string } }) { const [hasPlugin, setHasPlugin] = useState(false); // poll for plugin.html in the job folder; show the Plugin tab if it exists - useEffect(() => { - const checkPlugin = () => { + usePollLoop( + () => apiClient .get(`/api/jobs/${jobID}/plugin?check=1`) .then(res => res.data) .then(data => setHasPlugin(!!data.exists)) - .catch(() => {}); - }; - checkPlugin(); - const interval = setInterval(checkPlugin, 5000); - return () => clearInterval(interval); - }, [jobID]); + .catch(() => {}), + 5000, + [jobID], + ); const page = pages.find(p => p.value === pageKey); diff --git a/ui/src/components/GPUMonitor.tsx b/ui/src/components/GPUMonitor.tsx index fe1d7ad..e28a1ee 100644 --- a/ui/src/components/GPUMonitor.tsx +++ b/ui/src/components/GPUMonitor.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import { GPUApiResponse } from '@/types'; import Loading from '@/components/Loading'; import GPUWidget from '@/components/GPUWidget'; import { apiClient } from '@/utils/api'; +import usePollLoop from '@/hooks/usePollLoop'; const GpuMonitor: React.FC = () => { const [gpuData, setGpuData] = useState(null); @@ -11,39 +12,32 @@ const GpuMonitor: React.FC = () => { const [lastUpdated, setLastUpdated] = useState(null); const isFetchingGpuRef = useRef(false); - useEffect(() => { - const fetchGpuInfo = async () => { - if (isFetchingGpuRef.current) { - return; - } - setLoading(true); - isFetchingGpuRef.current = true; - apiClient - .get('/api/gpu') - .then(res => res.data) - .then(data => { - setGpuData(data); - setLastUpdated(new Date()); - setError(null); - }) - .catch(err => { - setError(`Failed to fetch GPU data: ${err instanceof Error ? err.message : String(err)}`); - }) - .finally(() => { - isFetchingGpuRef.current = false; - setLoading(false); - }); - }; + const fetchGpuInfo = () => { + if (isFetchingGpuRef.current) { + return; + } + setLoading(true); + isFetchingGpuRef.current = true; + return apiClient + .get('/api/gpu') + .then(res => res.data) + .then(data => { + setGpuData(data); + setLastUpdated(new Date()); + setError(null); + }) + .catch(err => { + setError(`Failed to fetch GPU data: ${err instanceof Error ? err.message : String(err)}`); + }) + .finally(() => { + isFetchingGpuRef.current = false; + setLoading(false); + }); + }; - // Fetch immediately on component mount - fetchGpuInfo(); - - // Set up interval to fetch every 1 seconds - const intervalId = setInterval(fetchGpuInfo, 1000); - - // Clean up interval on component unmount - return () => clearInterval(intervalId); - }, []); + // Fetch every second, but only schedule the next fetch after the current + // one finishes so slow responses can't stack requests + usePollLoop(fetchGpuInfo, 1000); const getGridClasses = (gpuCount: number): string => { switch (gpuCount) { diff --git a/ui/src/components/OstrisCloudBalance.tsx b/ui/src/components/OstrisCloudBalance.tsx index 90d2f52..c371d5a 100644 --- a/ui/src/components/OstrisCloudBalance.tsx +++ b/ui/src/components/OstrisCloudBalance.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Wallet } from 'lucide-react'; import { apiClient } from '@/utils/api'; +import usePollLoop from '@/hooks/usePollLoop'; interface BalanceResponse { enabled: boolean; @@ -17,26 +18,14 @@ interface BalanceResponse { export default function OstrisCloudBalance() { const [data, setData] = useState(null); - useEffect(() => { - let cancelled = false; - - const fetchBalance = async () => { - try { - const res = await apiClient.get('/api/ostris_cloud'); - if (!cancelled) setData(res.data); - } catch { - // ignore — keep last known value - } - }; - - fetchBalance(); - const interval = setInterval(fetchBalance, 60_000); - - return () => { - cancelled = true; - clearInterval(interval); - }; - }, []); + usePollLoop(async () => { + try { + const res = await apiClient.get('/api/ostris_cloud'); + setData(res.data); + } catch { + // ignore — keep last known value + } + }, 60_000); if (!data || !data.enabled || !data.appUrl) return null; diff --git a/ui/src/hooks/useCPUInfo.tsx b/ui/src/hooks/useCPUInfo.tsx index 913b749..7563f44 100644 --- a/ui/src/hooks/useCPUInfo.tsx +++ b/ui/src/hooks/useCPUInfo.tsx @@ -1,8 +1,9 @@ 'use client'; import { CpuInfo } from '@/types'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { apiClient } from '@/utils/api'; +import usePollLoop from '@/hooks/usePollLoop'; export default function useCPUInfo(reloadInterval: null | number = null) { const [cpuInfo, setCpuInfo] = useState(null); @@ -23,22 +24,7 @@ export default function useCPUInfo(reloadInterval: null | number = null) { } }; - useEffect(() => { - // Fetch immediately on component mount - fetchCpuInfo(); - - // Set up interval if specified - if (reloadInterval) { - const interval = setInterval(() => { - fetchCpuInfo(); - }, reloadInterval); - - // Cleanup interval on unmount - return () => { - clearInterval(interval); - }; - } - }, [reloadInterval]); // Added dependencies + usePollLoop(fetchCpuInfo, reloadInterval); return { cpuInfo, isCPUInfoLoaded, status, refreshCpuInfo: fetchCpuInfo }; } diff --git a/ui/src/hooks/useFilesList.tsx b/ui/src/hooks/useFilesList.tsx index e73e6c6..02affd0 100644 --- a/ui/src/hooks/useFilesList.tsx +++ b/ui/src/hooks/useFilesList.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useEffect, useState, useRef } from 'react'; +import { useState, useRef } from 'react'; import { apiClient } from '@/utils/api'; +import usePollLoop from '@/hooks/usePollLoop'; interface FileObject { path: string; @@ -19,7 +20,7 @@ export default function useFilesList(jobID: string, reloadInterval: null | numbe loadStatus = 'refreshing'; } setStatus(loadStatus); - apiClient + return apiClient .get(`/api/jobs/${jobID}/files`) .then(res => res.data) .then(data => { @@ -36,19 +37,7 @@ export default function useFilesList(jobID: string, reloadInterval: null | numbe }); }; - useEffect(() => { - refreshFiles(); - - if (reloadInterval) { - const interval = setInterval(() => { - refreshFiles(); - }, reloadInterval); - - return () => { - clearInterval(interval); - }; - } - }, [jobID]); + usePollLoop(refreshFiles, reloadInterval, [jobID]); return { files, setFiles, status, refreshFiles }; } diff --git a/ui/src/hooks/useGPUInfo.tsx b/ui/src/hooks/useGPUInfo.tsx index 1e43865..4cb69a5 100644 --- a/ui/src/hooks/useGPUInfo.tsx +++ b/ui/src/hooks/useGPUInfo.tsx @@ -3,6 +3,7 @@ import { GPUApiResponse, GpuInfo } from '@/types'; import { useEffect, useState } from 'react'; import { apiClient } from '@/utils/api'; +import usePollLoop from '@/hooks/usePollLoop'; export default function useGPUInfo(gpuIds: null | number[] = null, reloadInterval: null | number = null) { const [gpuList, setGpuList] = useState([]); @@ -27,22 +28,7 @@ export default function useGPUInfo(gpuIds: null | number[] = null, reloadInterva } }; - useEffect(() => { - // Fetch immediately on component mount - fetchGpuInfo(); - - // Set up interval if specified - if (reloadInterval) { - const interval = setInterval(() => { - fetchGpuInfo(); - }, reloadInterval); - - // Cleanup interval on unmount - return () => { - clearInterval(interval); - }; - } - }, [gpuIds, reloadInterval]); // Added dependencies + usePollLoop(fetchGpuInfo, reloadInterval, [gpuIds]); // If the initial fetch failed (e.g. nvidia-smi was transiently busy on startup), // retry automatically so users don't have to reload the page to see their GPUs. diff --git a/ui/src/hooks/useJob.tsx b/ui/src/hooks/useJob.tsx index 5c43f9e..18a700e 100644 --- a/ui/src/hooks/useJob.tsx +++ b/ui/src/hooks/useJob.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Job } from '@prisma/client'; import { apiClient } from '@/utils/api'; +import usePollLoop from '@/hooks/usePollLoop'; export default function useJob(jobID: string, reloadInterval: null | number = null) { const [job, setJob] = useState(null); @@ -10,7 +11,7 @@ export default function useJob(jobID: string, reloadInterval: null | number = nu const refreshJob = () => { setStatus('loading'); - apiClient + return apiClient .get(`/api/jobs?id=${jobID}`) .then(res => res.data) .then(data => { @@ -24,19 +25,7 @@ export default function useJob(jobID: string, reloadInterval: null | number = nu }); }; - useEffect(() => { - refreshJob(); - - if (reloadInterval) { - const interval = setInterval(() => { - refreshJob(); - }, reloadInterval); - - return () => { - clearInterval(interval); - }; - } - }, [jobID]); + usePollLoop(refreshJob, reloadInterval, [jobID]); return { job, setJob, status, refreshJob }; } diff --git a/ui/src/hooks/useJobByRef.tsx b/ui/src/hooks/useJobByRef.tsx index 9c39eb9..1bc425f 100644 --- a/ui/src/hooks/useJobByRef.tsx +++ b/ui/src/hooks/useJobByRef.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Job } from '@prisma/client'; import { apiClient } from '@/utils/api'; +import usePollLoop from '@/hooks/usePollLoop'; export default function useJobByRef(jobRef: string | null, reloadInterval: null | number = null) { const [job, setJob] = useState(null); @@ -10,7 +11,7 @@ export default function useJobByRef(jobRef: string | null, reloadInterval: null const refreshJob = () => { setStatus('loading'); - apiClient + return apiClient .get(`/api/jobs?job_ref=${jobRef}`) .then(res => res.data) .then(data => { @@ -24,19 +25,7 @@ export default function useJobByRef(jobRef: string | null, reloadInterval: null }); }; - useEffect(() => { - refreshJob(); - - if (reloadInterval) { - const interval = setInterval(() => { - refreshJob(); - }, reloadInterval); - - return () => { - clearInterval(interval); - }; - } - }, [jobRef, reloadInterval]); + usePollLoop(refreshJob, reloadInterval, [jobRef]); return { job, setJob, status, refreshJob }; } diff --git a/ui/src/hooks/useJobLog.tsx b/ui/src/hooks/useJobLog.tsx index 001e4dc..43b3d88 100644 --- a/ui/src/hooks/useJobLog.tsx +++ b/ui/src/hooks/useJobLog.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useRef } from 'react'; import { apiClient } from '@/utils/api'; import { TerminalEmulator } from '@/utils/terminalEmulator'; +import usePollLoop from '@/hooks/usePollLoop'; interface FileObject { path: string; @@ -38,7 +39,7 @@ export default function useJobLog(jobID: string, reloadInterval: null | number = } setStatus(loadStatus); const offset = offsetRef.current; - apiClient + return apiClient .get(`/api/jobs/${jobID}/log`, offset !== null ? { params: { offset } } : undefined) .then(res => res.data) .then(data => { @@ -62,25 +63,17 @@ export default function useJobLog(jobID: string, reloadInterval: null | number = }); }; + // New job — start fresh. Declared before the poll loop so the reset runs + // before the first fetch when jobID changes. useEffect(() => { - // New job — start fresh. offsetRef.current = null; didInitialLoadRef.current = false; inFlightRef.current = false; terminalRef.current?.reset(); setLog(''); - refresh(); - - if (reloadInterval) { - const interval = setInterval(() => { - refresh(); - }, reloadInterval); - - return () => { - clearInterval(interval); - }; - } }, [jobID]); + usePollLoop(refresh, reloadInterval, [jobID]); + return { log, status, refresh }; } diff --git a/ui/src/hooks/useJobLossLog.tsx b/ui/src/hooks/useJobLossLog.tsx index a29860d..380a5e6 100644 --- a/ui/src/hooks/useJobLossLog.tsx +++ b/ui/src/hooks/useJobLossLog.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { apiClient } from '@/utils/api'; +import usePollLoop from '@/hooks/usePollLoop'; export interface LossPoint { step: number; @@ -115,24 +116,17 @@ export default function useJobLossLog(jobID: string, reloadInterval: null | numb } }, [jobID, reloadInterval]); + // reset when job changes. Declared before the poll loop so the reset runs + // before the first fetch when jobID changes. useEffect(() => { - // reset when job changes didInitialLoadRef.current = false; lastStepByKeyRef.current = {}; setSeries({}); setKeys([]); setStatus('idle'); + }, [jobID]); - refreshLoss(); - - if (reloadInterval) { - const interval = setInterval(() => { - refreshLoss(); - }, reloadInterval); - - return () => clearInterval(interval); - } - }, [jobID, reloadInterval, refreshLoss]); + usePollLoop(refreshLoss, reloadInterval, [jobID]); return { series, keys, lossKeys, status, refreshLoss, setSeries }; } diff --git a/ui/src/hooks/useJobsList.tsx b/ui/src/hooks/useJobsList.tsx index 6309b55..7a7ae02 100644 --- a/ui/src/hooks/useJobsList.tsx +++ b/ui/src/hooks/useJobsList.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { Job } from '@prisma/client'; import { apiClient } from '@/utils/api'; +import usePollLoop from '@/hooks/usePollLoop'; type UseJobsListProps = { onlyActive?: boolean; @@ -30,7 +31,7 @@ export default function useJobsList({ if (onlyActive) { params.only_active = 'true'; } - apiClient + return apiClient .get('/api/jobs', { params }) .then(res => res.data) .then(data => { @@ -51,16 +52,7 @@ export default function useJobsList({ isFetchingRef.current = false; }); }; - useEffect(() => { - refreshJobs(); - - if (reloadInterval) { - const interval = setInterval(() => { - refreshJobs(); - }, reloadInterval); - return () => clearInterval(interval); - } - }, []); + usePollLoop(refreshJobs, reloadInterval); return { jobs, setJobs, status, refreshJobs }; } diff --git a/ui/src/hooks/usePollLoop.tsx b/ui/src/hooks/usePollLoop.tsx new file mode 100644 index 0000000..c9048d6 --- /dev/null +++ b/ui/src/hooks/usePollLoop.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +/** + * Polling loop that schedules the next run only after the current one + * settles, so a slow server can't stack overlapping requests the way + * setInterval does. Worst case the UI just updates late. + * + * - `fn` should return its promise so we know when the request finished. + * - Errors from `fn` are caught here so a failed poll never kills the loop. + * - `intervalMs` of null/0 runs `fn` once with no polling (matches the old + * reloadInterval=null behavior). + * - Changing `deps` (or `intervalMs`) restarts the loop, running `fn` + * immediately. + */ +export default function usePollLoop( + fn: () => void | Promise, + intervalMs: number | null | undefined, + deps: unknown[] = [], +) { + // always call the latest fn without restarting the loop on every render + const fnRef = useRef(fn); + fnRef.current = fn; + + useEffect(() => { + let stopped = false; + let timeout: ReturnType | undefined; + + const tick = async () => { + try { + await fnRef.current(); + } catch (error) { + console.error('Poll error:', error); + } + if (stopped || !intervalMs) return; + timeout = setTimeout(tick, intervalMs); + }; + + tick(); + + return () => { + stopped = true; + if (timeout) clearTimeout(timeout); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [intervalMs, ...deps]); +} diff --git a/ui/src/hooks/useSampleImages.tsx b/ui/src/hooks/useSampleImages.tsx index 8b79a8d..35ca9f0 100644 --- a/ui/src/hooks/useSampleImages.tsx +++ b/ui/src/hooks/useSampleImages.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { apiClient } from '@/utils/api'; +import usePollLoop from '@/hooks/usePollLoop'; export default function useSampleImages(jobID: string, reloadInterval: null | number = null) { const [sampleImages, setSampleImages] = useState([]); @@ -9,7 +10,7 @@ export default function useSampleImages(jobID: string, reloadInterval: null | nu const refreshSampleImages = () => { setStatus('loading'); - apiClient + return apiClient .get(`/api/jobs/${jobID}/samples`) .then(res => res.data) .then(data => { @@ -25,19 +26,7 @@ export default function useSampleImages(jobID: string, reloadInterval: null | nu }); }; - useEffect(() => { - refreshSampleImages(); - - if (reloadInterval) { - const interval = setInterval(() => { - refreshSampleImages(); - }, reloadInterval); - - return () => { - clearInterval(interval); - }; - } - }, [jobID]); + usePollLoop(refreshSampleImages, reloadInterval, [jobID]); return { sampleImages, setSampleImages, status, refreshSampleImages }; }