import { Hype } from '@because/hype' import { define, stylesToCSS } from '@because/forge' import { baseStyles, ToolScript, theme } from '@because/toes/tools' import { existsSync, readdirSync, statSync } from 'fs' import { join } from 'path' import type { Child } from 'hono/jsx' // ============================================================================ // Configuration // ============================================================================ const DATA_HISTORY_MAX_DAYS = 30 // Keep 30 days of data size history const SAMPLE_INTERVAL_MS = 10_000 // How often to sample process metrics const HISTORY_MAX_SAMPLES = 60 // Keep 10 minutes of history at 10s intervals const APPS_DIR = process.env.APPS_DIR! const TOES_DIR = process.env.TOES_DIR! const TOES_URL = process.env.TOES_URL! // ============================================================================ // Types // ============================================================================ interface App { name: string state: string port?: number pid?: number tool?: boolean } interface AppMetrics extends App { cpu?: number dataSize?: number memory?: number rss?: number } interface HistorySample { timestamp: number cpu: number memory: number rss: number } interface DataSample { date: string bytes: number } interface ProcessMetrics { pid: number cpu: number memory: number rss: number } // ============================================================================ // Process Metrics Collection // ============================================================================ const appHistory = new Map() // app name -> history const dataHistory = new Map() // app name -> daily data size const metricsCache = new Map() function getDataHistory(appName: string): DataSample[] { return dataHistory.get(appName) ?? [] } function getHistory(appName: string): HistorySample[] { return appHistory.get(appName) ?? [] } function getProcessMetrics(pid: number): ProcessMetrics | undefined { return metricsCache.get(pid) } function recordHistory(appName: string, cpu: number, memory: number, rss: number): void { const history = appHistory.get(appName) ?? [] history.push({ timestamp: Date.now(), cpu, memory, rss, }) // Keep only the last N samples while (history.length > HISTORY_MAX_SAMPLES) { history.shift() } appHistory.set(appName, history) } async function sampleProcessMetrics(): Promise { try { const proc = Bun.spawn(['ps', '-eo', 'pid,pcpu,pmem,rss'], { stdout: 'pipe', stderr: 'ignore', }) const text = await new Response(proc.stdout).text() const lines = text.trim().split('\n').slice(1) // Skip header metricsCache.clear() for (const line of lines) { const parts = line.trim().split(/\s+/) if (parts.length >= 4) { const pid = parseInt(parts[0]!, 10) const cpu = parseFloat(parts[1]!) const memory = parseFloat(parts[2]!) const rss = parseInt(parts[3]!, 10) // KB if (!isNaN(pid) && pid > 0) { metricsCache.set(pid, { pid, cpu, memory, rss }) } } } } catch (err) { console.error('Failed to sample process metrics:', err) } } function recordDataSize(appName: string): void { const today = new Date().toISOString().slice(0, 10) const history = dataHistory.get(appName) ?? [] const bytes = getDataSize(appName) // Update today's entry or add new one const existing = history.find(s => s.date === today) if (existing) { existing.bytes = bytes } else { history.push({ date: today, bytes }) } // Keep only the last N days while (history.length > DATA_HISTORY_MAX_DAYS) { history.shift() } dataHistory.set(appName, history) } async function sampleAndRecordHistory(): Promise { await sampleProcessMetrics() try { const res = await fetch(`${TOES_URL}/api/apps`) if (!res.ok) return const apps = await res.json() as App[] // Record process history for running apps for (const app of apps) { if (app.pid && app.state === 'running') { const metrics = getProcessMetrics(app.pid) if (metrics) { recordHistory(app.name, metrics.cpu, metrics.memory, metrics.rss) } } } // Record data size history for all apps (filesystem-based, not process-based) for (const app of apps) { recordDataSize(app.name) } } catch { // Ignore errors } } // Start sampling on module load sampleAndRecordHistory() setInterval(sampleAndRecordHistory, SAMPLE_INTERVAL_MS) // ============================================================================ // Data Size // ============================================================================ function getDataSize(appName: string): number { let total = 0 const appDir = join(APPS_DIR, appName) if (existsSync(appDir)) total += dirSize(appDir) const dataDir = join(TOES_DIR, appName) if (existsSync(dataDir)) total += dirSize(dataDir) return total } function dirSize(dir: string): number { let total = 0 for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name) if (entry.isDirectory()) { total += dirSize(path) } else { total += statSync(path).size } } return total } // ============================================================================ // API Client // ============================================================================ async function fetchApps(): Promise { try { const res = await fetch(`${TOES_URL}/api/apps`) if (!res.ok) return [] return await res.json() as App[] } catch { return [] } } async function getAppMetrics(): Promise { const apps = await fetchApps() return apps.map(app => { const metrics = app.pid ? getProcessMetrics(app.pid) : undefined return { ...app, cpu: metrics?.cpu, dataSize: getDataSize(app.name), memory: metrics?.memory, rss: metrics?.rss, } }) } async function getAppMetricsByName(name: string): Promise { const apps = await fetchApps() const app = apps.find(a => a.name === name) if (!app) return undefined const metrics = app.pid ? getProcessMetrics(app.pid) : undefined return { ...app, cpu: metrics?.cpu, dataSize: getDataSize(app.name), memory: metrics?.memory, rss: metrics?.rss, } } // ============================================================================ // Styled Components // ============================================================================ const Container = define('Container', { fontFamily: theme('fonts-sans'), padding: '20px', paddingTop: 0, maxWidth: '900px', margin: '0 auto', color: theme('colors-text'), }) const Table = define('Table', { base: 'table', width: '100%', borderCollapse: 'collapse', fontSize: '14px', fontFamily: theme('fonts-mono'), }) const Th = define('Th', { base: 'th', textAlign: 'left', padding: '10px 12px', borderBottom: `2px solid ${theme('colors-border')}`, color: theme('colors-textMuted'), fontSize: '12px', fontWeight: 'normal', textTransform: 'uppercase', letterSpacing: '0.5px', }) const ThRight = define('ThRight', { base: 'th', textAlign: 'right', padding: '10px 12px', borderBottom: `2px solid ${theme('colors-border')}`, color: theme('colors-textMuted'), fontSize: '12px', fontWeight: 'normal', textTransform: 'uppercase', letterSpacing: '0.5px', }) const Tr = define('Tr', { base: 'tr', states: { ':hover': { backgroundColor: theme('colors-bgHover'), }, }, }) const Td = define('Td', { base: 'td', padding: '10px 12px', borderBottom: `1px solid ${theme('colors-border')}`, }) const TdRight = define('TdRight', { base: 'td', padding: '10px 12px', borderBottom: `1px solid ${theme('colors-border')}`, textAlign: 'right', }) const StatusBadge = define('StatusBadge', { base: 'span', fontSize: '12px', padding: '2px 6px', borderRadius: theme('radius-md'), }) const ToolBadge = define('ToolBadge', { base: 'span', fontSize: '11px', marginLeft: '6px', color: theme('colors-textMuted'), }) const Summary = define('Summary', { marginTop: '20px', padding: '12px 15px', backgroundColor: theme('colors-bgElement'), borderRadius: theme('radius-md'), fontSize: '13px', color: theme('colors-textMuted'), }) const EmptyState = define('EmptyState', { padding: '40px 20px', textAlign: 'center', color: theme('colors-textMuted'), }) const ChartsContainer = define('ChartsContainer', { marginTop: '24px', display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '20px', }) const ChartCard = define('ChartCard', { backgroundColor: theme('colors-bgElement'), borderRadius: theme('radius-md'), padding: '16px', border: `1px solid ${theme('colors-border')}`, }) const ChartTitle = define('ChartTitle', { margin: '0 0 12px 0', fontSize: '13px', fontWeight: 600, color: theme('colors-textMuted'), textTransform: 'uppercase', letterSpacing: '0.5px', }) const ChartWrapper = define('ChartWrapper', { position: 'relative', height: '150px', }) const NoDataMessage = define('NoDataMessage', { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: theme('colors-textMuted'), fontSize: '13px', }) // ============================================================================ // Helpers // ============================================================================ function formatBytes(bytes?: number): string { if (bytes === undefined) return '-' if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB` return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB` } function formatPercent(value?: number): string { if (value === undefined) return '-' return `${value.toFixed(1)}%` } function formatRss(kb?: number): string { if (kb === undefined) return '-' if (kb < 1024) return `${kb} KB` if (kb < 1024 * 1024) return `${(kb / 1024).toFixed(1)} MB` return `${(kb / 1024 / 1024).toFixed(2)} GB` } function getStatusColor(state: string): string { switch (state) { case 'running': return theme('colors-statusRunning') case 'stopped': case 'stopping': return theme('colors-statusStopped') case 'invalid': return theme('colors-error') default: return theme('colors-textMuted') } } // ============================================================================ // Layout // ============================================================================ interface LayoutProps { title: string children: Child } function Layout({ title, children }: LayoutProps) { return ( {title} {children} ) } // ============================================================================ // App // ============================================================================ const app = new Hype({ prettyHTML: false }) app.get('/ok', c => c.text('ok')) app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8', })) // API endpoint for CLI app.get('/api/metrics', async c => { const metrics = await getAppMetrics() return c.json(metrics) }) app.get('/api/metrics/:name', async c => { const name = c.req.param('name') const metrics = await getAppMetricsByName(name) if (!metrics) { return c.json({ error: 'App not found' }, 404) } return c.json(metrics) }) app.get('/api/data-history/:name', c => { const name = c.req.param('name') const history = getDataHistory(name) return c.json(history) }) app.get('/api/history/:name', c => { const name = c.req.param('name') const history = getHistory(name) return c.json(history) }) // Web UI app.get('/', async c => { const appName = c.req.query('app') // Single app view if (appName) { const metrics = await getAppMetricsByName(appName) if (!metrics) { return c.html( App not found: {appName} ) } return c.html( PIDCPUMEMRSSData{metrics.pid ?? '-'}{formatPercent(metrics.cpu)}{formatPercent(metrics.memory)}{formatRss(metrics.rss)}{formatBytes(metrics.dataSize)}
State
{metrics.state}
CPU Usage Memory % RSS Memory Data Size