import { allApps, APPS_DIR, onChange } from '$apps' import { onHostLog } from '../tui' import { Hype } from '@because/hype' import { cpus, freemem, platform, totalmem } from 'os' import { join } from 'path' import { readFileSync, statfsSync } from 'fs' export interface AppMetrics { cpu: number mem: number disk: number } export interface SystemMetrics { cpu: number // 0-100 ram: { used: number, total: number, percent: number } disk: { used: number, total: number, percent: number } apps: Record } export interface UnifiedLogLine { time: number app: string text: string } const router = Hype.router() // Unified log buffer const unifiedLogs: UnifiedLogLine[] = [] const MAX_UNIFIED_LOGS = 200 const unifiedLogListeners = new Set<(line: UnifiedLogLine) => void>() // Track last log counts per app for delta detection const lastLogCounts = new Map() // CPU tracking let lastCpuTimes: { idle: number, total: number } | null = null function getCpuUsage(): number { const cpuList = cpus() let idle = 0 let total = 0 for (const cpu of cpuList) { idle += cpu.times.idle total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq } if (!lastCpuTimes) { lastCpuTimes = { idle, total } return 0 } const idleDiff = idle - lastCpuTimes.idle const totalDiff = total - lastCpuTimes.total lastCpuTimes = { idle, total } if (totalDiff === 0) return 0 return Math.round((1 - idleDiff / totalDiff) * 100) } function getMemoryUsage(): { used: number, total: number, percent: number } { const total = totalmem() if (platform() === 'linux') { try { const meminfo = readFileSync('/proc/meminfo', 'utf-8') const available = meminfo.match(/MemAvailable:\s+(\d+)/) if (available) { const availableBytes = parseInt(available[1]!, 10) * 1024 const used = total - availableBytes return { used, total, percent: Math.round((used / total) * 100) } } } catch {} } // macOS fallback const free = freemem() const used = total - free return { used, total, percent: Math.round((used / total) * 100) } } function getDiskUsage(): { used: number, total: number, percent: number } { try { const stats = statfsSync('/') const total = stats.blocks * stats.bsize const free = stats.bfree * stats.bsize const used = total - free return { used, total, percent: Math.round((used / total) * 100), } } catch { return { used: 0, total: 0, percent: 0 } } } // Per-app disk cache (updated every 30s) let _appDiskCache: Record = {} let _appDiskLastUpdate = 0 const DISK_CACHE_TTL = 30000 async function getAppMetrics(): Promise> { const apps = allApps() const running = apps.filter(a => a.proc?.pid) const result: Record = {} // CPU + MEM via ps (works on both macOS and Linux) const pidToName = new Map() for (const app of running) { pidToName.set(app.proc!.pid, app.name) } if (pidToName.size > 0) { try { const pids = [...pidToName.keys()].join(',') const proc = Bun.spawn(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids], { stdout: 'pipe', stderr: 'ignore' }) const output = await new Response(proc.stdout).text() await proc.exited for (const line of output.split('\n')) { const parts = line.trim().split(/\s+/) if (parts.length < 3) continue const pid = parseInt(parts[0]!, 10) const cpu = parseFloat(parts[1]!) || 0 const mem = (parseInt(parts[2]!, 10) || 0) * 1024 const name = pidToName.get(pid) if (name) result[name] = { cpu: Math.round(cpu), mem, disk: 0 } } } catch {} } // Disk usage per app (cached) const now = Date.now() if (now - _appDiskLastUpdate > DISK_CACHE_TTL) { _appDiskLastUpdate = now _appDiskCache = {} const duResults = await Promise.all( apps.map(async app => { try { const proc = Bun.spawn(['du', '-sk', join(APPS_DIR, app.name)], { stdout: 'pipe', stderr: 'ignore' }) const output = await new Response(proc.stdout).text() await proc.exited const kb = parseInt(output.trim().split('\t')[0]!, 10) return { name: app.name, bytes: kb ? kb * 1024 : 0 } } catch { return { name: app.name, bytes: 0 } } }) ) for (const { name, bytes } of duResults) { if (bytes) _appDiskCache[name] = bytes } } // Merge disk into results, fill in stopped apps for (const app of apps) { if (!result[app.name]) result[app.name] = { cpu: 0, mem: 0, disk: 0 } result[app.name]!.disk = _appDiskCache[app.name] ?? 0 } return result } // Get current system metrics router.get('/metrics', async c => { const metrics: SystemMetrics = { cpu: getCpuUsage(), ram: getMemoryUsage(), disk: getDiskUsage(), apps: await getAppMetrics(), } return c.json(metrics) }) // SSE stream for real-time metrics (updates every 2s) router.sse('/metrics/stream', (send) => { let queue = Promise.resolve() const sendMetrics = () => { queue = queue.then(async () => { const metrics: SystemMetrics = { cpu: getCpuUsage(), ram: getMemoryUsage(), disk: getDiskUsage(), apps: await getAppMetrics(), } await send(metrics) }) } // Initial send sendMetrics() // Update every 2 seconds const interval = setInterval(sendMetrics, 2000) return () => clearInterval(interval) }) // System info const projectRoot = join(import.meta.dir, '../../..') const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf-8')) const sha = Bun.spawnSync(['git', 'rev-parse', '--short', 'HEAD'], { cwd: projectRoot }).stdout.toString().trim() || 'unknown' let isUpdating = false router.get('/info', c => { return c.json({ version: pkg.version, sha }) }) // Get recent unified logs router.get('/logs', c => { const tail = c.req.query('tail') const count = tail ? parseInt(tail, 10) : MAX_UNIFIED_LOGS return c.json(unifiedLogs.slice(-count)) }) // Clear unified logs router.post('/logs/clear', c => { unifiedLogs.length = 0 return c.json({ ok: true }) }) // SSE stream for unified logs router.sse('/logs/stream', (send) => { // Send existing logs first for (const line of unifiedLogs.slice(-50)) { send(line) } // Subscribe to new logs const listener = (line: UnifiedLogLine) => send(line) unifiedLogListeners.add(listener) return () => unifiedLogListeners.delete(listener) }) // Collect logs from all apps function collectLogs() { const apps = allApps() for (const app of apps) { const logs = app.logs ?? [] const lastCount = lastLogCounts.get(app.name) ?? 0 // Get new logs since last check const newLogs = logs.slice(lastCount) lastLogCounts.set(app.name, logs.length) for (const log of newLogs) { const line: UnifiedLogLine = { time: log.time, app: app.name, text: log.text, } // Add to buffer unifiedLogs.push(line) if (unifiedLogs.length > MAX_UNIFIED_LOGS) { unifiedLogs.shift() } // Notify listeners for (const listener of unifiedLogListeners) { listener(line) } } } } function pushHostLog(text: string) { const line: UnifiedLogLine = { time: Date.now(), app: 'toes', text } unifiedLogs.push(line) if (unifiedLogs.length > MAX_UNIFIED_LOGS) unifiedLogs.shift() for (const listener of unifiedLogListeners) listener(line) } // Subscribe to app changes to collect logs onChange(collectLogs) // Subscribe to host-level log messages onHostLog(pushHostLog) // Restart server (systemd brings it back) router.post('/restart', c => { setTimeout(() => process.exit(0), 100) return c.json({ ok: true }) }) // Check for updates router.get('/update', async c => { try { const gitFetch = await Bun.spawn(['git', 'fetch', 'origin', 'main'], { cwd: projectRoot }).exited if (gitFetch !== 0) return c.json({ available: false }) const log = Bun.spawnSync(['git', 'log', 'HEAD..origin/main', '--oneline'], { cwd: projectRoot }) const output = log.stdout.toString().trim() const commits = output ? output.split('\n') : [] const latest = Bun.spawnSync(['git', 'rev-parse', '--short', 'origin/main'], { cwd: projectRoot }) .stdout.toString().trim() return c.json({ available: commits.length > 0, current: sha, latest: commits.length > 0 ? latest : sha, commits, }) } catch { return c.json({ available: false }) } }) // Apply update and restart (delegates to install/install.sh) // The script ends with `systemctl restart toes`, killing this process, // so we respond immediately and run it detached. router.post('/update', c => { if (isUpdating) return c.json({ ok: false, error: 'update already in progress' }, 409) isUpdating = true try { const script = join(projectRoot, 'install/install.sh') const proc = Bun.spawn(['bash', script], { cwd: projectRoot, stdout: 'ignore', stderr: 'ignore' }) proc.exited.then(code => { if (code !== 0) isUpdating = false }) .catch(() => { isUpdating = false }) return c.json({ ok: true }) } catch { isUpdating = false return c.json({ ok: false, error: 'failed to start update' }, 500) } }) export default router