Improve ui polling code to prevent poll buildup
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
48
ui/src/hooks/usePollLoop.tsx
Normal file
48
ui/src/hooks/usePollLoop.tsx
Normal 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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user