Improve ui polling code to prevent poll buildup

This commit is contained in:
Jaret Burkett
2026-07-21 11:35:52 -06:00
parent e54a0fe78c
commit 3a4341dee3
13 changed files with 129 additions and 192 deletions

View File

@@ -1,11 +1,12 @@
'use client'; 'use client';
import { useState, useEffect, use } from 'react'; import { useState, use } from 'react';
import { FaChevronLeft } from 'react-icons/fa'; import { FaChevronLeft } from 'react-icons/fa';
import { MdDashboard, MdImage, MdShowChart, MdCode, MdExtension } from 'react-icons/md'; import { MdDashboard, MdImage, MdShowChart, MdCode, MdExtension } from 'react-icons/md';
import { Button } from '@headlessui/react'; import { Button } from '@headlessui/react';
import { TopBar, MainContent } from '@/components/layout'; import { TopBar, MainContent } from '@/components/layout';
import useJob from '@/hooks/useJob'; import useJob from '@/hooks/useJob';
import usePollLoop from '@/hooks/usePollLoop';
import SampleImages, { SampleImagesMenu } from '@/components/SampleImages'; import SampleImages, { SampleImagesMenu } from '@/components/SampleImages';
import JobOverview from '@/components/JobOverview'; import JobOverview from '@/components/JobOverview';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
@@ -77,18 +78,16 @@ export default function JobPage({ params }: { params: { jobID: string } }) {
const [hasPlugin, setHasPlugin] = useState(false); const [hasPlugin, setHasPlugin] = useState(false);
// poll for plugin.html in the job folder; show the Plugin tab if it exists // poll for plugin.html in the job folder; show the Plugin tab if it exists
useEffect(() => { usePollLoop(
const checkPlugin = () => { () =>
apiClient apiClient
.get(`/api/jobs/${jobID}/plugin?check=1`) .get(`/api/jobs/${jobID}/plugin?check=1`)
.then(res => res.data) .then(res => res.data)
.then(data => setHasPlugin(!!data.exists)) .then(data => setHasPlugin(!!data.exists))
.catch(() => {}); .catch(() => {}),
}; 5000,
checkPlugin(); [jobID],
const interval = setInterval(checkPlugin, 5000); );
return () => clearInterval(interval);
}, [jobID]);
const page = pages.find(p => p.value === pageKey); const page = pages.find(p => p.value === pageKey);

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'; import React, { useState, useRef, useMemo } from 'react';
import { GPUApiResponse } from '@/types'; import { GPUApiResponse } from '@/types';
import Loading from '@/components/Loading'; import Loading from '@/components/Loading';
import GPUWidget from '@/components/GPUWidget'; import GPUWidget from '@/components/GPUWidget';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import usePollLoop from '@/hooks/usePollLoop';
const GpuMonitor: React.FC = () => { const GpuMonitor: React.FC = () => {
const [gpuData, setGpuData] = useState<GPUApiResponse | null>(null); const [gpuData, setGpuData] = useState<GPUApiResponse | null>(null);
@@ -11,39 +12,32 @@ const GpuMonitor: React.FC = () => {
const [lastUpdated, setLastUpdated] = useState<Date | null>(null); const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const isFetchingGpuRef = useRef(false); const isFetchingGpuRef = useRef(false);
useEffect(() => { const fetchGpuInfo = () => {
const fetchGpuInfo = async () => { if (isFetchingGpuRef.current) {
if (isFetchingGpuRef.current) { return;
return; }
} setLoading(true);
setLoading(true); isFetchingGpuRef.current = true;
isFetchingGpuRef.current = true; return apiClient
apiClient .get('/api/gpu')
.get('/api/gpu') .then(res => res.data)
.then(res => res.data) .then(data => {
.then(data => { setGpuData(data);
setGpuData(data); setLastUpdated(new Date());
setLastUpdated(new Date()); setError(null);
setError(null); })
}) .catch(err => {
.catch(err => { setError(`Failed to fetch GPU data: ${err instanceof Error ? err.message : String(err)}`);
setError(`Failed to fetch GPU data: ${err instanceof Error ? err.message : String(err)}`); })
}) .finally(() => {
.finally(() => { isFetchingGpuRef.current = false;
isFetchingGpuRef.current = false; setLoading(false);
setLoading(false); });
}); };
};
// Fetch immediately on component mount // Fetch every second, but only schedule the next fetch after the current
fetchGpuInfo(); // one finishes so slow responses can't stack requests
usePollLoop(fetchGpuInfo, 1000);
// Set up interval to fetch every 1 seconds
const intervalId = setInterval(fetchGpuInfo, 1000);
// Clean up interval on component unmount
return () => clearInterval(intervalId);
}, []);
const getGridClasses = (gpuCount: number): string => { const getGridClasses = (gpuCount: number): string => {
switch (gpuCount) { switch (gpuCount) {

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { Wallet } from 'lucide-react'; import { Wallet } from 'lucide-react';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import usePollLoop from '@/hooks/usePollLoop';
interface BalanceResponse { interface BalanceResponse {
enabled: boolean; enabled: boolean;
@@ -17,26 +18,14 @@ interface BalanceResponse {
export default function OstrisCloudBalance() { export default function OstrisCloudBalance() {
const [data, setData] = useState<BalanceResponse | null>(null); const [data, setData] = useState<BalanceResponse | null>(null);
useEffect(() => { usePollLoop(async () => {
let cancelled = false; try {
const res = await apiClient.get<BalanceResponse>('/api/ostris_cloud');
const fetchBalance = async () => { setData(res.data);
try { } catch {
const res = await apiClient.get<BalanceResponse>('/api/ostris_cloud'); // ignore — keep last known value
if (!cancelled) setData(res.data); }
} catch { }, 60_000);
// ignore — keep last known value
}
};
fetchBalance();
const interval = setInterval(fetchBalance, 60_000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
if (!data || !data.enabled || !data.appUrl) return null; if (!data || !data.enabled || !data.appUrl) return null;

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { CpuInfo } from '@/types'; import { CpuInfo } from '@/types';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import usePollLoop from '@/hooks/usePollLoop';
export default function useCPUInfo(reloadInterval: null | number = null) { export default function useCPUInfo(reloadInterval: null | number = null) {
const [cpuInfo, setCpuInfo] = useState<CpuInfo | null>(null); const [cpuInfo, setCpuInfo] = useState<CpuInfo | null>(null);
@@ -23,22 +24,7 @@ export default function useCPUInfo(reloadInterval: null | number = null) {
} }
}; };
useEffect(() => { usePollLoop(fetchCpuInfo, reloadInterval);
// 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
return { cpuInfo, isCPUInfoLoaded, status, refreshCpuInfo: fetchCpuInfo }; return { cpuInfo, isCPUInfoLoaded, status, refreshCpuInfo: fetchCpuInfo };
} }

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useEffect, useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import usePollLoop from '@/hooks/usePollLoop';
interface FileObject { interface FileObject {
path: string; path: string;
@@ -19,7 +20,7 @@ export default function useFilesList(jobID: string, reloadInterval: null | numbe
loadStatus = 'refreshing'; loadStatus = 'refreshing';
} }
setStatus(loadStatus); setStatus(loadStatus);
apiClient return apiClient
.get(`/api/jobs/${jobID}/files`) .get(`/api/jobs/${jobID}/files`)
.then(res => res.data) .then(res => res.data)
.then(data => { .then(data => {
@@ -36,19 +37,7 @@ export default function useFilesList(jobID: string, reloadInterval: null | numbe
}); });
}; };
useEffect(() => { usePollLoop(refreshFiles, reloadInterval, [jobID]);
refreshFiles();
if (reloadInterval) {
const interval = setInterval(() => {
refreshFiles();
}, reloadInterval);
return () => {
clearInterval(interval);
};
}
}, [jobID]);
return { files, setFiles, status, refreshFiles }; return { files, setFiles, status, refreshFiles };
} }

View File

@@ -3,6 +3,7 @@
import { GPUApiResponse, GpuInfo } from '@/types'; import { GPUApiResponse, GpuInfo } from '@/types';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import usePollLoop from '@/hooks/usePollLoop';
export default function useGPUInfo(gpuIds: null | number[] = null, reloadInterval: null | number = null) { export default function useGPUInfo(gpuIds: null | number[] = null, reloadInterval: null | number = null) {
const [gpuList, setGpuList] = useState<GpuInfo[]>([]); const [gpuList, setGpuList] = useState<GpuInfo[]>([]);
@@ -27,22 +28,7 @@ export default function useGPUInfo(gpuIds: null | number[] = null, reloadInterva
} }
}; };
useEffect(() => { usePollLoop(fetchGpuInfo, reloadInterval, [gpuIds]);
// 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
// If the initial fetch failed (e.g. nvidia-smi was transiently busy on startup), // 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. // retry automatically so users don't have to reload the page to see their GPUs.

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { Job } from '@prisma/client'; import { Job } from '@prisma/client';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import usePollLoop from '@/hooks/usePollLoop';
export default function useJob(jobID: string, reloadInterval: null | number = null) { export default function useJob(jobID: string, reloadInterval: null | number = null) {
const [job, setJob] = useState<Job | null>(null); const [job, setJob] = useState<Job | null>(null);
@@ -10,7 +11,7 @@ export default function useJob(jobID: string, reloadInterval: null | number = nu
const refreshJob = () => { const refreshJob = () => {
setStatus('loading'); setStatus('loading');
apiClient return apiClient
.get(`/api/jobs?id=${jobID}`) .get(`/api/jobs?id=${jobID}`)
.then(res => res.data) .then(res => res.data)
.then(data => { .then(data => {
@@ -24,19 +25,7 @@ export default function useJob(jobID: string, reloadInterval: null | number = nu
}); });
}; };
useEffect(() => { usePollLoop(refreshJob, reloadInterval, [jobID]);
refreshJob();
if (reloadInterval) {
const interval = setInterval(() => {
refreshJob();
}, reloadInterval);
return () => {
clearInterval(interval);
};
}
}, [jobID]);
return { job, setJob, status, refreshJob }; return { job, setJob, status, refreshJob };
} }

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { Job } from '@prisma/client'; import { Job } from '@prisma/client';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import usePollLoop from '@/hooks/usePollLoop';
export default function useJobByRef(jobRef: string | null, reloadInterval: null | number = null) { export default function useJobByRef(jobRef: string | null, reloadInterval: null | number = null) {
const [job, setJob] = useState<Job | null>(null); const [job, setJob] = useState<Job | null>(null);
@@ -10,7 +11,7 @@ export default function useJobByRef(jobRef: string | null, reloadInterval: null
const refreshJob = () => { const refreshJob = () => {
setStatus('loading'); setStatus('loading');
apiClient return apiClient
.get(`/api/jobs?job_ref=${jobRef}`) .get(`/api/jobs?job_ref=${jobRef}`)
.then(res => res.data) .then(res => res.data)
.then(data => { .then(data => {
@@ -24,19 +25,7 @@ export default function useJobByRef(jobRef: string | null, reloadInterval: null
}); });
}; };
useEffect(() => { usePollLoop(refreshJob, reloadInterval, [jobRef]);
refreshJob();
if (reloadInterval) {
const interval = setInterval(() => {
refreshJob();
}, reloadInterval);
return () => {
clearInterval(interval);
};
}
}, [jobRef, reloadInterval]);
return { job, setJob, status, refreshJob }; return { job, setJob, status, refreshJob };
} }

View File

@@ -3,6 +3,7 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import { TerminalEmulator } from '@/utils/terminalEmulator'; import { TerminalEmulator } from '@/utils/terminalEmulator';
import usePollLoop from '@/hooks/usePollLoop';
interface FileObject { interface FileObject {
path: string; path: string;
@@ -38,7 +39,7 @@ export default function useJobLog(jobID: string, reloadInterval: null | number =
} }
setStatus(loadStatus); setStatus(loadStatus);
const offset = offsetRef.current; const offset = offsetRef.current;
apiClient return apiClient
.get(`/api/jobs/${jobID}/log`, offset !== null ? { params: { offset } } : undefined) .get(`/api/jobs/${jobID}/log`, offset !== null ? { params: { offset } } : undefined)
.then(res => res.data) .then(res => res.data)
.then(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(() => { useEffect(() => {
// New job — start fresh.
offsetRef.current = null; offsetRef.current = null;
didInitialLoadRef.current = false; didInitialLoadRef.current = false;
inFlightRef.current = false; inFlightRef.current = false;
terminalRef.current?.reset(); terminalRef.current?.reset();
setLog(''); setLog('');
refresh();
if (reloadInterval) {
const interval = setInterval(() => {
refresh();
}, reloadInterval);
return () => {
clearInterval(interval);
};
}
}, [jobID]); }, [jobID]);
usePollLoop(refresh, reloadInterval, [jobID]);
return { log, status, refresh }; return { log, status, refresh };
} }

View File

@@ -2,6 +2,7 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import usePollLoop from '@/hooks/usePollLoop';
export interface LossPoint { export interface LossPoint {
step: number; step: number;
@@ -115,24 +116,17 @@ export default function useJobLossLog(jobID: string, reloadInterval: null | numb
} }
}, [jobID, reloadInterval]); }, [jobID, reloadInterval]);
// reset when job changes. Declared before the poll loop so the reset runs
// before the first fetch when jobID changes.
useEffect(() => { useEffect(() => {
// reset when job changes
didInitialLoadRef.current = false; didInitialLoadRef.current = false;
lastStepByKeyRef.current = {}; lastStepByKeyRef.current = {};
setSeries({}); setSeries({});
setKeys([]); setKeys([]);
setStatus('idle'); setStatus('idle');
}, [jobID]);
refreshLoss(); usePollLoop(refreshLoss, reloadInterval, [jobID]);
if (reloadInterval) {
const interval = setInterval(() => {
refreshLoss();
}, reloadInterval);
return () => clearInterval(interval);
}
}, [jobID, reloadInterval, refreshLoss]);
return { series, keys, lossKeys, status, refreshLoss, setSeries }; return { series, keys, lossKeys, status, refreshLoss, setSeries };
} }

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Job } from '@prisma/client'; import { Job } from '@prisma/client';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import usePollLoop from '@/hooks/usePollLoop';
type UseJobsListProps = { type UseJobsListProps = {
onlyActive?: boolean; onlyActive?: boolean;
@@ -30,7 +31,7 @@ export default function useJobsList({
if (onlyActive) { if (onlyActive) {
params.only_active = 'true'; params.only_active = 'true';
} }
apiClient return apiClient
.get('/api/jobs', { params }) .get('/api/jobs', { params })
.then(res => res.data) .then(res => res.data)
.then(data => { .then(data => {
@@ -51,16 +52,7 @@ export default function useJobsList({
isFetchingRef.current = false; isFetchingRef.current = false;
}); });
}; };
useEffect(() => { usePollLoop(refreshJobs, reloadInterval);
refreshJobs();
if (reloadInterval) {
const interval = setInterval(() => {
refreshJobs();
}, reloadInterval);
return () => clearInterval(interval);
}
}, []);
return { jobs, setJobs, status, refreshJobs }; return { jobs, setJobs, status, refreshJobs };
} }

View File

@@ -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<unknown>,
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<typeof setTimeout> | 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]);
}

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import usePollLoop from '@/hooks/usePollLoop';
export default function useSampleImages(jobID: string, reloadInterval: null | number = null) { export default function useSampleImages(jobID: string, reloadInterval: null | number = null) {
const [sampleImages, setSampleImages] = useState<string[]>([]); const [sampleImages, setSampleImages] = useState<string[]>([]);
@@ -9,7 +10,7 @@ export default function useSampleImages(jobID: string, reloadInterval: null | nu
const refreshSampleImages = () => { const refreshSampleImages = () => {
setStatus('loading'); setStatus('loading');
apiClient return apiClient
.get(`/api/jobs/${jobID}/samples`) .get(`/api/jobs/${jobID}/samples`)
.then(res => res.data) .then(res => res.data)
.then(data => { .then(data => {
@@ -25,19 +26,7 @@ export default function useSampleImages(jobID: string, reloadInterval: null | nu
}); });
}; };
useEffect(() => { usePollLoop(refreshSampleImages, reloadInterval, [jobID]);
refreshSampleImages();
if (reloadInterval) {
const interval = setInterval(() => {
refreshSampleImages();
}, reloadInterval);
return () => {
clearInterval(interval);
};
}
}, [jobID]);
return { sampleImages, setSampleImages, status, refreshSampleImages }; return { sampleImages, setSampleImages, status, refreshSampleImages };
} }