toes/src/server/api/system.ts
2026-02-19 09:28:15 -08:00

313 lines
7.9 KiB
TypeScript

import { allApps, APPS_DIR, onChange, TOES_DIR } from '$apps'
import { onHostLog } from '../tui'
import { Hype } from '@because/hype'
import { cpus, platform, totalmem } from 'os'
import { join } from 'path'
import { existsSync, mkdirSync, readFileSync, statfsSync, writeFileSync } 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<string, AppMetrics>
}
export interface WifiConfig {
network: string
password: string
}
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<string, number>()
// 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()
const apps = allApps().filter(a => a.proc?.pid)
let used = 0
if (platform() === 'linux') {
for (const app of apps) {
try {
const status = readFileSync(`/proc/${app.proc!.pid}/status`, 'utf-8')
const match = status.match(/VmRSS:\s+(\d+)/)
if (match) used += parseInt(match[1]!, 10) * 1024
} catch {}
}
} else {
// macOS: batch ps call for all pids
const pids = apps.map(a => a.proc!.pid).join(',')
if (pids) {
try {
const result = Bun.spawnSync(['ps', '-o', 'rss=', '-p', pids])
const output = result.stdout.toString()
for (const line of output.split('\n')) {
const kb = parseInt(line.trim(), 10)
if (kb) used += kb * 1024
}
} catch {}
}
}
return {
used,
total,
percent: used > 0 ? Math.max(1, Math.round((used / total) * 100)) : 0,
}
}
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<string, number> = {}
let _appDiskLastUpdate = 0
const DISK_CACHE_TTL = 30000
function getAppMetrics(): Record<string, AppMetrics> {
const apps = allApps()
const running = apps.filter(a => a.proc?.pid)
const result: Record<string, AppMetrics> = {}
// CPU + MEM via ps (works on both macOS and Linux)
const pidToName = new Map<number, string>()
for (const app of running) {
pidToName.set(app.proc!.pid, app.name)
}
if (pidToName.size > 0) {
try {
const pids = [...pidToName.keys()].join(',')
const ps = Bun.spawnSync(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids])
for (const line of ps.stdout.toString().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 = {}
for (const app of apps) {
try {
const du = Bun.spawnSync(['du', '-sk', join(APPS_DIR, app.name)])
const kb = parseInt(du.stdout.toString().trim().split('\t')[0]!, 10)
if (kb) _appDiskCache[app.name] = kb * 1024
} catch {}
}
}
// 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', c => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: getAppMetrics(),
}
return c.json(metrics)
})
// SSE stream for real-time metrics (updates every 2s)
router.sse('/metrics/stream', (send) => {
const sendMetrics = () => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: getAppMetrics(),
}
send(metrics)
}
// Initial send
sendMetrics()
// Update every 2 seconds
const interval = setInterval(sendMetrics, 2000)
return () => clearInterval(interval)
})
// WiFi config
const CONFIG_DIR = join(TOES_DIR, 'config')
const WIFI_PATH = join(CONFIG_DIR, 'wifi.json')
function readWifiConfig(): WifiConfig {
try {
if (existsSync(WIFI_PATH)) {
return JSON.parse(readFileSync(WIFI_PATH, 'utf-8'))
}
} catch {}
return { network: '', password: '' }
}
function writeWifiConfig(config: WifiConfig) {
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
writeFileSync(WIFI_PATH, JSON.stringify(config, null, 2))
}
router.get('/wifi', c => {
return c.json(readWifiConfig())
})
router.put('/wifi', async c => {
const body = await c.req.json<WifiConfig>()
const config: WifiConfig = {
network: String(body.network ?? ''),
password: String(body.password ?? ''),
}
writeWifiConfig(config)
return c.json(config)
})
// 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)
export default router