diff --git a/ui/src/app/api/cpu/route.ts b/ui/src/app/api/cpu/route.ts index 61fd2da..12eab7a 100644 --- a/ui/src/app/api/cpu/route.ts +++ b/ui/src/app/api/cpu/route.ts @@ -3,44 +3,32 @@ import si from 'systeminformation'; import { createRequire } from 'module'; import os from 'os'; import { CpuInfo } from '@/types'; +import { cached } from '@/server/apiCache'; const isMac = os.platform() === 'darwin'; -export async function GET() { - try { - const cpuInfoRaw = await si.cpu(); - let cpuInfo: CpuInfo; +async function getCpuInfo(): Promise { + const cpuInfoRaw = await si.cpu(); + let cpuInfo: CpuInfo; - if (isMac) { - try { - const nativeRequire = createRequire(import.meta.url); - const ms = nativeRequire('macstats') as any; - const ramData = ms.getRAMUsageSync(); - const cpuData = ms.getCpuDataSync(); + if (isMac) { + try { + const nativeRequire = createRequire(import.meta.url); + const ms = nativeRequire('macstats') as any; + const ramData = ms.getRAMUsageSync(); + const cpuData = ms.getCpuDataSync(); - cpuInfo = { - name: `${cpuInfoRaw.manufacturer} ${cpuInfoRaw.brand}`, - cores: cpuInfoRaw.cores, - temperature: cpuData.temperature || 0, - totalMemory: ramData.total / (1024 * 1024), - availableMemory: ramData.free / (1024 * 1024), - freeMemory: ramData.free / (1024 * 1024), - currentLoad: (await si.currentLoad()).currentLoad || 0, - }; - } catch { - // Fallback to systeminformation if macstats fails - const memoryData = await si.mem(); - cpuInfo = { - name: `${cpuInfoRaw.manufacturer} ${cpuInfoRaw.brand}`, - cores: cpuInfoRaw.cores, - temperature: (await si.cpuTemperature()).main || 0, - totalMemory: memoryData.total / (1024 * 1024), - availableMemory: memoryData.available / (1024 * 1024), - freeMemory: memoryData.free / (1024 * 1024), - currentLoad: (await si.currentLoad()).currentLoad || 0, - }; - } - } else { + cpuInfo = { + name: `${cpuInfoRaw.manufacturer} ${cpuInfoRaw.brand}`, + cores: cpuInfoRaw.cores, + temperature: cpuData.temperature || 0, + totalMemory: ramData.total / (1024 * 1024), + availableMemory: ramData.free / (1024 * 1024), + freeMemory: ramData.free / (1024 * 1024), + currentLoad: (await si.currentLoad()).currentLoad || 0, + }; + } catch { + // Fallback to systeminformation if macstats fails const memoryData = await si.mem(); cpuInfo = { name: `${cpuInfoRaw.manufacturer} ${cpuInfoRaw.brand}`, @@ -52,7 +40,25 @@ export async function GET() { currentLoad: (await si.currentLoad()).currentLoad || 0, }; } + } else { + const memoryData = await si.mem(); + cpuInfo = { + name: `${cpuInfoRaw.manufacturer} ${cpuInfoRaw.brand}`, + cores: cpuInfoRaw.cores, + temperature: (await si.cpuTemperature()).main || 0, + totalMemory: memoryData.total / (1024 * 1024), + availableMemory: memoryData.available / (1024 * 1024), + freeMemory: memoryData.free / (1024 * 1024), + currentLoad: (await si.currentLoad()).currentLoad || 0, + }; + } + return cpuInfo; +} + +export async function GET() { + try { + const cpuInfo = await cached('cpu-info', getCpuInfo); return NextResponse.json(cpuInfo); } catch (error) { console.error('Error fetching CPU stats:', error); diff --git a/ui/src/app/api/gpu/route.ts b/ui/src/app/api/gpu/route.ts index 67093e6..ffb47c2 100644 --- a/ui/src/app/api/gpu/route.ts +++ b/ui/src/app/api/gpu/route.ts @@ -3,6 +3,7 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { createRequire } from 'module'; import os from 'os'; +import { cached } from '@/server/apiCache'; const execAsync = promisify(exec); @@ -93,68 +94,73 @@ async function getMacGpuInfo(): Promise { } } -export async function GET() { - try { - // Get platform - const platform = os.platform(); - const isWindows = platform === 'win32'; - const isMac = platform === 'darwin'; +async function getGpuInfo() { + // Get platform + const platform = os.platform(); + const isWindows = platform === 'win32'; + const isMac = platform === 'darwin'; - if (isMac) { - const macGpu = await getMacGpuInfo(); - if (macGpu) { - return NextResponse.json({ - hasNvidiaSmi: false, - isMac: true, - gpus: [ - { - index: 0, - name: macGpu.name, - driverVersion: 'macOS', - temperature: Math.round(macGpu.temperature), - utilization: { - gpu: macGpu.gpuLoad, - memory: macGpu.memTotal > 0 ? Math.round((macGpu.memUsed / macGpu.memTotal) * 100) : 0, - }, - memory: { - total: Math.round(macGpu.memTotal), - free: Math.round(macGpu.memTotal - macGpu.memUsed), - used: Math.round(macGpu.memUsed), - }, - power: { draw: macGpu.powerDraw, limit: 0 }, - clocks: { graphics: 0, memory: 0 }, - fan: { speed: macGpu.fanSpeed }, - }, - ], - }); - } - return NextResponse.json({ + if (isMac) { + const macGpu = await getMacGpuInfo(); + if (macGpu) { + return { hasNvidiaSmi: false, isMac: true, - gpus: [], - error: 'Could not read Mac GPU stats', - }); + gpus: [ + { + index: 0, + name: macGpu.name, + driverVersion: 'macOS', + temperature: Math.round(macGpu.temperature), + utilization: { + gpu: macGpu.gpuLoad, + memory: macGpu.memTotal > 0 ? Math.round((macGpu.memUsed / macGpu.memTotal) * 100) : 0, + }, + memory: { + total: Math.round(macGpu.memTotal), + free: Math.round(macGpu.memTotal - macGpu.memUsed), + used: Math.round(macGpu.memUsed), + }, + power: { draw: macGpu.powerDraw, limit: 0 }, + clocks: { graphics: 0, memory: 0 }, + fan: { speed: macGpu.fanSpeed }, + }, + ], + }; } + return { + hasNvidiaSmi: false, + isMac: true, + gpus: [], + error: 'Could not read Mac GPU stats', + }; + } - // Check if nvidia-smi is available - const hasNvidiaSmi = await checkNvidiaSmi(isWindows); + // Check if nvidia-smi is available + const hasNvidiaSmi = await checkNvidiaSmi(isWindows); - if (!hasNvidiaSmi) { - return NextResponse.json({ - hasNvidiaSmi: false, - isMac: false, - gpus: [], - error: 'nvidia-smi not found or not accessible', - }); - } + if (!hasNvidiaSmi) { + return { + hasNvidiaSmi: false, + isMac: false, + gpus: [], + error: 'nvidia-smi not found or not accessible', + }; + } - // Get GPU stats - const gpuStats = await getGpuStats(isWindows); + // Get GPU stats + const gpuStats = await getGpuStats(isWindows); - return NextResponse.json({ - hasNvidiaSmi: true, - gpus: gpuStats, - }); + return { + hasNvidiaSmi: true, + gpus: gpuStats, + }; +} + +export async function GET() { + try { + const gpuInfo = await cached('gpu-info', getGpuInfo); + return NextResponse.json(gpuInfo); } catch (error) { console.error('Error fetching NVIDIA GPU stats:', error); return NextResponse.json( @@ -248,4 +254,3 @@ async function getGpuStats(isWindows: boolean) { return gpus; } - diff --git a/ui/src/server/apiCache.ts b/ui/src/server/apiCache.ts new file mode 100644 index 0000000..c892806 --- /dev/null +++ b/ui/src/server/apiCache.ts @@ -0,0 +1,59 @@ +type CacheEntry = { + promise: Promise; + timestamp: number; +}; + +const cache = new Map(); + +const DEFAULT_STALE_TIME_MS = 5000; + +/** + * Universal cache for slow API work. Results are cached per key + params and + * reused until they go stale. Concurrent callers while a fetch is in flight + * share the same promise instead of triggering duplicate work. + * + * @param key Unique name for this cached operation (e.g. 'cpu-info') + * @param fetcher Function that produces a fresh value + * @param staleTimeMs How long a cached value stays fresh (default 5000ms) + * @param params Optional params; different params get separate cache entries + */ +export async function cached( + key: string, + fetcher: () => Promise, + staleTimeMs: number = DEFAULT_STALE_TIME_MS, + params: unknown = null, +): Promise { + const cacheKey = params === null ? key : `${key}:${JSON.stringify(params)}`; + const now = Date.now(); + + const entry = cache.get(cacheKey); + if (entry && now - entry.timestamp < staleTimeMs) { + return entry.promise as Promise; + } + + const promise = fetcher(); + cache.set(cacheKey, { promise, timestamp: now }); + + // Drop failed fetches so the next call retries instead of caching the error + promise.catch(() => { + if (cache.get(cacheKey)?.promise === promise) { + cache.delete(cacheKey); + } + }); + + return promise; +} + +/** Remove a cached entry (all param variants if no params given). */ +export function invalidateCache(key: string, params: unknown = null): void { + if (params !== null) { + cache.delete(`${key}:${JSON.stringify(params)}`); + return; + } + cache.delete(key); + for (const cacheKey of cache.keys()) { + if (cacheKey.startsWith(`${key}:`)) { + cache.delete(cacheKey); + } + } +}