Files
web-app-launcher/src/lib/server/services/systemStatsService.ts
T
alexei.dolgolyov 1c0a7cb850 feat: Phases 4-7 — Full Feature Expansion (26 features)
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.
2026-03-25 14:18:10 +03:00

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