313 lines
7.9 KiB
TypeScript
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
|