1c0a7cb850
Phase 4 — New Widget Types: - Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown, Metric/Counter, Link Group, Camera/Stream widgets - Backend services with caching for each data source - Full creation form with dynamic config fields per type Phase 5 — Visual & Styling Enhancements: - Glassmorphism card style (solid/glass/outline) - Board-level themes with per-board hue/saturation - Animated SVG status rings replacing static dots - Card size options (compact/medium/large) - Custom CSS injection (admin + per-board, sanitized) - Wallpaper backgrounds with blur/overlay/parallax Phase 6 — Functional Features: - Favorites bar with drag-and-drop reordering - Recent apps tracking with privacy toggle - Uptime dashboard page (/status, guest-accessible) - Notifications system (Discord/Slack/Telegram/HTTP webhooks) - App tags with filtering in board view - Multi-URL app cards with expandable sub-links - Personal API tokens with scoped permissions - Audit log with retention and admin viewer Phase 7 — Quality of Life: - Onboarding wizard (5-step first-launch setup) - App URL health preview with favicon/title detection - Board templates (4 built-in + custom import/export) - Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help) 212 files changed, 15641 insertions, 980 deletions. Build, lint, type check, and 222 tests all pass.
218 lines
5.8 KiB
TypeScript
218 lines
5.8 KiB
TypeScript
/**
|
|
* 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<string, CacheEntry>();
|
|
|
|
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<unknown> {
|
|
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<readonly SystemMetric[]> {
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<readonly SystemMetric[]> {
|
|
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<string, unknown>;
|
|
|
|
if (data.status === 'success') {
|
|
const result = data.data as Record<string, unknown>;
|
|
const resultArray = result?.result as Array<Record<string, unknown>> | 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<readonly SystemMetric[]> {
|
|
const results: SystemMetric[] = [];
|
|
|
|
try {
|
|
const data = (await fetchWithTimeout(sourceUrl)) as Record<string, unknown>;
|
|
|
|
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<readonly SystemMetric[]> {
|
|
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();
|
|
}
|