325 lines
8.8 KiB
TypeScript
325 lines
8.8 KiB
TypeScript
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<string, AppMetrics>
|
|
}
|
|
|
|
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()
|
|
|
|
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<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)
|
|
})
|
|
|
|
// 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
|
|
router.post('/update', async c => {
|
|
if (isUpdating) return c.json({ ok: false, error: 'update already in progress' }, 409)
|
|
isUpdating = true
|
|
try {
|
|
const pull = await Bun.spawn(['git', 'pull', 'origin', 'main'], { cwd: projectRoot }).exited
|
|
if (pull !== 0) return c.json({ ok: false, error: 'git pull failed' }, 500)
|
|
|
|
const install = await Bun.spawn(['bun', 'install'], { cwd: projectRoot }).exited
|
|
if (install !== 0) return c.json({ ok: false, error: 'bun install failed' }, 500)
|
|
|
|
const build = await Bun.spawn(['bun', 'run', 'build'], { cwd: projectRoot }).exited
|
|
if (build !== 0) return c.json({ ok: false, error: 'build failed' }, 500)
|
|
|
|
setTimeout(() => process.exit(0), 100)
|
|
return c.json({ ok: true })
|
|
} catch {
|
|
return c.json({ ok: false, error: 'update failed' }, 500)
|
|
} finally {
|
|
isUpdating = false
|
|
}
|
|
})
|
|
|
|
export default router
|