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,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<CpuInfo | null>(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 };
}

View File

@@ -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 };
}

View File

@@ -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<GpuInfo[]>([]);
@@ -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.

View File

@@ -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<Job | null>(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 };
}

View File

@@ -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<Job | null>(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 };
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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 };
}

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';
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<string[]>([]);
@@ -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 };
}