/** * System stats service — fetches metrics from various sources using an adapter pattern. * Supports Glances, Prometheus, and custom JSON endpoints. */ const DEFAULT_CACHE_TTL_MS = 30_000; // 30 seconds const FETCH_TIMEOUT_MS = 10_000; interface CacheEntry { readonly data: readonly SystemMetric[]; readonly expiresAt: number; } export interface SystemMetric { readonly metric: string; readonly value: number; readonly unit: string; } const cache = new Map(); function getCached(key: string): readonly SystemMetric[] | null { const entry = cache.get(key); if (!entry) return null; if (Date.now() > entry.expiresAt) { cache.delete(key); return null; } return entry.data; } function setCache(key: string, data: readonly SystemMetric[], ttlMs: number): void { cache.set(key, { data, expiresAt: Date.now() + ttlMs }); } async function fetchWithTimeout(url: string): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'WebAppLauncher/1.0' } }); if (!response.ok) { throw new Error(`Source returned ${response.status}`); } return await response.json(); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { throw new Error('System stats request timed out'); } throw err; } finally { clearTimeout(timeoutId); } } /** * Glances adapter — fetches from Glances REST API. * Expects endpoints like /api/3/cpu, /api/3/mem, /api/3/fs */ async function fetchGlancesMetrics( sourceUrl: string, metrics: readonly string[] ): Promise { const results: SystemMetric[] = []; for (const metric of metrics) { try { const endpoint = `${sourceUrl.replace(/\/$/, '')}/api/3/${metric}`; const data = await fetchWithTimeout(endpoint); if (metric === 'cpu' && typeof data === 'object' && data !== null) { const cpuData = data as Record; const total = typeof cpuData.total === 'number' ? cpuData.total : 0; results.push({ metric: 'cpu', value: total, unit: '%' }); } else if (metric === 'mem' && typeof data === 'object' && data !== null) { const memData = data as Record; const percent = typeof memData.percent === 'number' ? memData.percent : 0; results.push({ metric: 'memory', value: percent, unit: '%' }); } else if (metric === 'fs' && Array.isArray(data)) { for (const disk of data) { const d = disk as Record; const percent = typeof d.percent === 'number' ? d.percent : 0; const mnt = typeof d.mnt_point === 'string' ? d.mnt_point : '/'; results.push({ metric: `disk:${mnt}`, value: percent, unit: '%' }); } } } catch { // Skip unreachable metric endpoints results.push({ metric, value: -1, unit: 'error' }); } } return results; } /** * Prometheus adapter — queries Prometheus instant query API. */ async function fetchPrometheusMetrics( sourceUrl: string, metrics: readonly string[] ): Promise { const results: SystemMetric[] = []; const baseUrl = sourceUrl.replace(/\/$/, ''); for (const query of metrics) { try { const endpoint = `${baseUrl}/api/v1/query?query=${encodeURIComponent(query)}`; const data = (await fetchWithTimeout(endpoint)) as Record; if (data.status === 'success') { const result = data.data as Record; const resultArray = result?.result as Array> | undefined; if (resultArray && resultArray.length > 0) { const value = resultArray[0].value as [number, string] | undefined; if (value && value.length === 2) { results.push({ metric: query, value: parseFloat(value[1]) || 0, unit: '' }); continue; } } } results.push({ metric: query, value: 0, unit: '' }); } catch { results.push({ metric: query, value: -1, unit: 'error' }); } } return results; } /** * Custom adapter — fetches from a generic JSON endpoint. * Expects the response to be an object with metric names as keys and numeric values. */ async function fetchCustomMetrics( sourceUrl: string, metrics: readonly string[] ): Promise { const results: SystemMetric[] = []; try { const data = (await fetchWithTimeout(sourceUrl)) as Record; for (const metric of metrics) { const value = data[metric]; if (typeof value === 'number') { results.push({ metric, value, unit: '' }); } else { results.push({ metric, value: 0, unit: '' }); } } } catch { for (const metric of metrics) { results.push({ metric, value: -1, unit: 'error' }); } } return results; } export type SourceType = 'glances' | 'prometheus' | 'custom'; /** * Fetch system stats from the specified source. */ export async function fetchSystemStats( sourceUrl: string, sourceType: SourceType, metrics: readonly string[], refreshInterval?: number ): Promise { const cacheKey = `${sourceType}:${sourceUrl}:${metrics.join(',')}`; const cached = getCached(cacheKey); if (cached) return cached; let result: readonly SystemMetric[]; switch (sourceType) { case 'glances': result = await fetchGlancesMetrics(sourceUrl, metrics); break; case 'prometheus': result = await fetchPrometheusMetrics(sourceUrl, metrics); break; case 'custom': result = await fetchCustomMetrics(sourceUrl, metrics); break; default: throw new Error(`Unknown source type: ${sourceType}`); } const ttl = refreshInterval ? refreshInterval * 1000 : DEFAULT_CACHE_TTL_MS; setCache(cacheKey, result, ttl); return result; } /** * Clear the system stats cache. */ export function clearCache(): void { cache.clear(); }