toes/apps/metrics/20260130-000000/index.tsx
Claude eb8ef0dd4d
Rename stats to metrics and add data size metric
Rename the "stats" CLI command, tool app, and all internal references
to "metrics". Add file size tracking from each app's DATA_DIR as a new
metric, shown in both the CLI table and web UI.

https://claude.ai/code/session_013agP8J1cCfrWZkueZ33jQB
2026-02-12 15:28:20 +00:00

781 lines
22 KiB
TypeScript

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 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 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 ProcessMetrics {
pid: number
cpu: number
memory: number
rss: number
}
// ============================================================================
// Process Metrics Collection
// ============================================================================
const appHistory = new Map<string, HistorySample[]>() // app name -> history
const metricsCache = new Map<number, ProcessMetrics>()
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<void> {
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)
}
}
async function sampleAndRecordHistory(): Promise<void> {
await sampleProcessMetrics()
// Record history for all running apps
try {
const res = await fetch(`${TOES_URL}/api/apps`)
if (!res.ok) return
const apps = await res.json() as App[]
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)
}
}
}
} catch {
// Ignore errors
}
}
// Start sampling on module load
sampleAndRecordHistory()
setInterval(sampleAndRecordHistory, SAMPLE_INTERVAL_MS)
// ============================================================================
// Data Size
// ============================================================================
function getDataSize(appName: string): number {
const dataDir = join(TOES_DIR, appName)
if (!existsSync(dataDir)) return 0
return dirSize(dataDir)
}
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<App[]> {
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<AppMetrics[]> {
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<AppMetrics | undefined> {
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(auto-fit, minmax(280px, 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 (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<ToolScript />
<Container>
{children}
</Container>
</body>
</html>
)
}
// ============================================================================
// 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/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(
<Layout title="Metrics">
<EmptyState>App not found: {appName}</EmptyState>
</Layout>
)
}
return c.html(
<Layout title="Metrics">
<Table>
<thead>
<tr>
<Th>State</Th>
<ThRight>PID</ThRight>
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr>
</thead>
<tbody>
<Tr>
<Td>
<StatusBadge style={`color: ${getStatusColor(metrics.state)}`}>
{metrics.state}
</StatusBadge>
</Td>
<TdRight>{metrics.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(metrics.cpu)}</TdRight>
<TdRight>{formatPercent(metrics.memory)}</TdRight>
<TdRight>{formatRss(metrics.rss)}</TdRight>
<TdRight>{formatBytes(metrics.dataSize)}</TdRight>
</Tr>
</tbody>
</Table>
<ChartsContainer>
<ChartCard>
<ChartTitle>CPU Usage</ChartTitle>
<ChartWrapper>
<canvas id="cpuChart"></canvas>
<NoDataMessage id="cpuNoData" style="display: none">
Collecting data...
</NoDataMessage>
</ChartWrapper>
</ChartCard>
<ChartCard>
<ChartTitle>Memory %</ChartTitle>
<ChartWrapper>
<canvas id="memChart"></canvas>
<NoDataMessage id="memNoData" style="display: none">
Collecting data...
</NoDataMessage>
</ChartWrapper>
</ChartCard>
<ChartCard>
<ChartTitle>RSS Memory</ChartTitle>
<ChartWrapper>
<canvas id="rssChart"></canvas>
<NoDataMessage id="rssNoData" style="display: none">
Collecting data...
</NoDataMessage>
</ChartWrapper>
</ChartCard>
</ChartsContainer>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script dangerouslySetInnerHTML={{__html: `
(function() {
const appName = ${JSON.stringify(appName)};
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const textColor = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)';
const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
const cpuColor = 'rgb(59, 130, 246)';
const memColor = 'rgb(168, 85, 247)';
const rssColor = 'rgb(34, 197, 94)';
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
}
},
scales: {
x: {
display: true,
grid: { color: gridColor },
ticks: {
color: textColor,
maxTicksLimit: 6,
callback: function(val, idx) {
const label = this.getLabelForValue(val);
return label;
}
}
},
y: {
display: true,
grid: { color: gridColor },
ticks: { color: textColor },
beginAtZero: true
}
},
elements: {
point: { radius: 0 },
line: { tension: 0.3, borderWidth: 2 }
}
};
let cpuChart, memChart, rssChart;
function formatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatRss(kb) {
if (kb < 1024) return kb + ' KB';
return (kb / 1024).toFixed(1) + ' MB';
}
function updateCharts(history) {
const labels = history.map(h => formatTime(h.timestamp));
const cpuData = history.map(h => h.cpu);
const memData = history.map(h => h.memory);
const rssData = history.map(h => h.rss / 1024); // Convert to MB
if (history.length === 0) {
document.getElementById('cpuChart').style.display = 'none';
document.getElementById('memChart').style.display = 'none';
document.getElementById('rssChart').style.display = 'none';
document.getElementById('cpuNoData').style.display = 'flex';
document.getElementById('memNoData').style.display = 'flex';
document.getElementById('rssNoData').style.display = 'flex';
return;
}
document.getElementById('cpuChart').style.display = 'block';
document.getElementById('memChart').style.display = 'block';
document.getElementById('rssChart').style.display = 'block';
document.getElementById('cpuNoData').style.display = 'none';
document.getElementById('memNoData').style.display = 'none';
document.getElementById('rssNoData').style.display = 'none';
if (!cpuChart) {
cpuChart = new Chart(document.getElementById('cpuChart'), {
type: 'line',
data: {
labels: labels,
datasets: [{
data: cpuData,
borderColor: cpuColor,
backgroundColor: cpuColor.replace('rgb', 'rgba').replace(')', ', 0.1)'),
fill: true
}]
},
options: {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
ticks: {
...commonOptions.scales.y.ticks,
callback: v => v.toFixed(1) + '%'
}
}
}
}
});
} else {
cpuChart.data.labels = labels;
cpuChart.data.datasets[0].data = cpuData;
cpuChart.update('none');
}
if (!memChart) {
memChart = new Chart(document.getElementById('memChart'), {
type: 'line',
data: {
labels: labels,
datasets: [{
data: memData,
borderColor: memColor,
backgroundColor: memColor.replace('rgb', 'rgba').replace(')', ', 0.1)'),
fill: true
}]
},
options: {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
ticks: {
...commonOptions.scales.y.ticks,
callback: v => v.toFixed(1) + '%'
}
}
}
}
});
} else {
memChart.data.labels = labels;
memChart.data.datasets[0].data = memData;
memChart.update('none');
}
if (!rssChart) {
rssChart = new Chart(document.getElementById('rssChart'), {
type: 'line',
data: {
labels: labels,
datasets: [{
data: rssData,
borderColor: rssColor,
backgroundColor: rssColor.replace('rgb', 'rgba').replace(')', ', 0.1)'),
fill: true
}]
},
options: {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
ticks: {
...commonOptions.scales.y.ticks,
callback: v => v.toFixed(1) + ' MB'
}
}
}
}
});
} else {
rssChart.data.labels = labels;
rssChart.data.datasets[0].data = rssData;
rssChart.update('none');
}
}
async function fetchHistory() {
try {
const res = await fetch('/api/history/' + encodeURIComponent(appName));
if (res.ok) {
const history = await res.json();
updateCharts(history);
}
} catch (e) {
console.error('Failed to fetch history:', e);
}
}
// Initial fetch
fetchHistory();
// Update every 10 seconds
setInterval(fetchHistory, 10000);
})();
`}} />
</Layout>
)
}
// All apps view
const metrics = await getAppMetrics()
// Sort: running first, then by name
metrics.sort((a, b) => {
if (a.state === 'running' && b.state !== 'running') return -1
if (a.state !== 'running' && b.state === 'running') return 1
return a.name.localeCompare(b.name)
})
if (metrics.length === 0) {
return c.html(
<Layout title="Metrics">
<EmptyState>No apps found</EmptyState>
</Layout>
)
}
const running = metrics.filter(s => s.state === 'running')
const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0)
const totalData = metrics.reduce((sum, s) => sum + (s.dataSize ?? 0), 0)
return c.html(
<Layout title="Metrics">
<Table>
<thead>
<tr>
<Th>Name</Th>
<Th>State</Th>
<ThRight>PID</ThRight>
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr>
</thead>
<tbody>
{metrics.map(s => (
<Tr>
<Td>
{s.name}
{s.tool && <ToolBadge>[tool]</ToolBadge>}
</Td>
<Td>
<StatusBadge style={`color: ${getStatusColor(s.state)}`}>
{s.state}
</StatusBadge>
</Td>
<TdRight>{s.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(s.cpu)}</TdRight>
<TdRight>{formatPercent(s.memory)}</TdRight>
<TdRight>{formatRss(s.rss)}</TdRight>
<TdRight>{formatBytes(s.dataSize)}</TdRight>
</Tr>
))}
</tbody>
</Table>
<Summary>
{running.length} running &middot; {formatPercent(totalCpu)} CPU &middot; {formatRss(totalRss)} RSS &middot; {formatBytes(totalData)} data
</Summary>
</Layout>
)
})
export default app.defaults