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';
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);

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 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<GPUApiResponse | null>(null);
@@ -11,39 +12,32 @@ const GpuMonitor: React.FC = () => {
const [lastUpdated, setLastUpdated] = useState<Date | null>(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) {

View File

@@ -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<BalanceResponse | null>(null);
useEffect(() => {
let cancelled = false;
const fetchBalance = async () => {
try {
const res = await apiClient.get<BalanceResponse>('/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<BalanceResponse>('/api/ostris_cloud');
setData(res.data);
} catch {
// ignore — keep last known value
}
}, 60_000);
if (!data || !data.enabled || !data.appUrl) return null;

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