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
This commit is contained in:
Claude 2026-02-12 15:28:20 +00:00
parent 9c128eaddc
commit eb8ef0dd4d
No known key found for this signature in database
9 changed files with 170 additions and 116 deletions

View File

@ -79,7 +79,7 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
CLI commands:
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `stats`, `cron`
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`
- **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash`
- **Config**: `config`, `env`, `versions`, `history`, `rollback`

View File

@ -1,15 +1,18 @@
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 stats
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!
// ============================================================================
@ -24,6 +27,13 @@ interface App {
tool?: boolean
}
interface AppMetrics extends App {
cpu?: number
dataSize?: number
memory?: number
rss?: number
}
interface HistorySample {
timestamp: number
cpu: number
@ -31,58 +41,26 @@ interface HistorySample {
rss: number
}
interface ProcessStats {
interface ProcessMetrics {
pid: number
cpu: number
memory: number
rss: number
}
interface AppStats extends App {
cpu?: number
memory?: number
rss?: number
}
// ============================================================================
// Process Stats Collection
// Process Metrics Collection
// ============================================================================
const statsCache = new Map<number, ProcessStats>()
const appHistory = new Map<string, HistorySample[]>() // app name -> history
const metricsCache = new Map<number, ProcessMetrics>()
async function sampleProcessStats(): 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
statsCache.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) {
statsCache.set(pid, { pid, cpu, memory, rss })
}
}
}
} catch (err) {
console.error('Failed to sample process stats:', err)
}
function getHistory(appName: string): HistorySample[] {
return appHistory.get(appName) ?? []
}
function getProcessStats(pid: number): ProcessStats | undefined {
return statsCache.get(pid)
function getProcessMetrics(pid: number): ProcessMetrics | undefined {
return metricsCache.get(pid)
}
function recordHistory(appName: string, cpu: number, memory: number, rss: number): void {
@ -102,12 +80,38 @@ function recordHistory(appName: string, cpu: number, memory: number, rss: number
appHistory.set(appName, history)
}
function getHistory(appName: string): HistorySample[] {
return appHistory.get(appName) ?? []
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 sampleProcessStats()
await sampleProcessMetrics()
// Record history for all running apps
try {
@ -117,9 +121,9 @@ async function sampleAndRecordHistory(): Promise<void> {
for (const app of apps) {
if (app.pid && app.state === 'running') {
const stats = getProcessStats(app.pid)
if (stats) {
recordHistory(app.name, stats.cpu, stats.memory, stats.rss)
const metrics = getProcessMetrics(app.pid)
if (metrics) {
recordHistory(app.name, metrics.cpu, metrics.memory, metrics.rss)
}
}
}
@ -132,6 +136,29 @@ async function sampleAndRecordHistory(): Promise<void> {
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
// ============================================================================
@ -146,30 +173,32 @@ async function fetchApps(): Promise<App[]> {
}
}
async function getAppStats(): Promise<AppStats[]> {
async function getAppMetrics(): Promise<AppMetrics[]> {
const apps = await fetchApps()
return apps.map(app => {
const stats = app.pid ? getProcessStats(app.pid) : undefined
const metrics = app.pid ? getProcessMetrics(app.pid) : undefined
return {
...app,
cpu: stats?.cpu,
memory: stats?.memory,
rss: stats?.rss,
cpu: metrics?.cpu,
dataSize: getDataSize(app.name),
memory: metrics?.memory,
rss: metrics?.rss,
}
})
}
async function getAppStatsByName(name: string): Promise<AppStats | undefined> {
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 stats = app.pid ? getProcessStats(app.pid) : undefined
const metrics = app.pid ? getProcessMetrics(app.pid) : undefined
return {
...app,
cpu: stats?.cpu,
memory: stats?.memory,
rss: stats?.rss,
cpu: metrics?.cpu,
dataSize: getDataSize(app.name),
memory: metrics?.memory,
rss: metrics?.rss,
}
}
@ -310,11 +339,12 @@ const NoDataMessage = define('NoDataMessage', {
// Helpers
// ============================================================================
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 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 {
@ -322,6 +352,13 @@ function formatPercent(value?: number): string {
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':
@ -377,18 +414,18 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
}))
// API endpoint for CLI
app.get('/api/stats', async c => {
const stats = await getAppStats()
return c.json(stats)
app.get('/api/metrics', async c => {
const metrics = await getAppMetrics()
return c.json(metrics)
})
app.get('/api/stats/:name', async c => {
app.get('/api/metrics/:name', async c => {
const name = c.req.param('name')
const stats = await getAppStatsByName(name)
if (!stats) {
const metrics = await getAppMetricsByName(name)
if (!metrics) {
return c.json({ error: 'App not found' }, 404)
}
return c.json(stats)
return c.json(metrics)
})
app.get('/api/history/:name', c => {
@ -403,18 +440,18 @@ app.get('/', async c => {
// Single app view
if (appName) {
const stats = await getAppStatsByName(appName)
const metrics = await getAppMetricsByName(appName)
if (!stats) {
if (!metrics) {
return c.html(
<Layout title="Stats">
<Layout title="Metrics">
<EmptyState>App not found: {appName}</EmptyState>
</Layout>
)
}
return c.html(
<Layout title="Stats">
<Layout title="Metrics">
<Table>
<thead>
<tr>
@ -423,19 +460,21 @@ app.get('/', async c => {
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr>
</thead>
<tbody>
<Tr>
<Td>
<StatusBadge style={`color: ${getStatusColor(stats.state)}`}>
{stats.state}
<StatusBadge style={`color: ${getStatusColor(metrics.state)}`}>
{metrics.state}
</StatusBadge>
</Td>
<TdRight>{stats.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(stats.cpu)}</TdRight>
<TdRight>{formatPercent(stats.memory)}</TdRight>
<TdRight>{formatRss(stats.rss)}</TdRight>
<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>
@ -674,29 +713,30 @@ app.get('/', async c => {
}
// All apps view
const stats = await getAppStats()
const metrics = await getAppMetrics()
// Sort: running first, then by name
stats.sort((a, b) => {
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 (stats.length === 0) {
if (metrics.length === 0) {
return c.html(
<Layout title="Stats">
<Layout title="Metrics">
<EmptyState>No apps found</EmptyState>
</Layout>
)
}
const running = stats.filter(s => s.state === 'running')
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="Stats">
<Layout title="Metrics">
<Table>
<thead>
<tr>
@ -706,10 +746,11 @@ app.get('/', async c => {
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr>
</thead>
<tbody>
{stats.map(s => (
{metrics.map(s => (
<Tr>
<Td>
{s.name}
@ -724,12 +765,13 @@ app.get('/', async c => {
<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)} total
{running.length} running &middot; {formatPercent(totalCpu)} CPU &middot; {formatRss(totalRss)} RSS &middot; {formatBytes(totalData)} data
</Summary>
</Layout>
)

View File

@ -1,5 +1,5 @@
{
"name": "stats",
"name": "metrics",
"module": "index.tsx",
"type": "module",
"private": true,

View File

@ -13,5 +13,5 @@ export {
startApp,
stopApp,
} from './manage'
export { statsApp } from './stats'
export { metricsApp } from './metrics'
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'

View File

@ -2,7 +2,7 @@ import color from 'kleur'
import { get } from '../http'
import { resolveAppName } from '../name'
interface AppStats {
interface AppMetrics {
name: string
state: string
port?: number
@ -10,9 +10,18 @@ interface AppStats {
cpu?: number
memory?: number
rss?: number
dataSize?: number
tool?: boolean
}
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 formatRss(kb?: number): string {
if (kb === undefined) return '-'
if (kb < 1024) return `${kb} KB`
@ -30,55 +39,56 @@ function pad(str: string, len: number, right = false): string {
return str.padEnd(len)
}
export async function statsApp(arg?: string) {
// If arg is provided, show stats for that app only
export async function metricsApp(arg?: string) {
// If arg is provided, show metrics for that app only
if (arg) {
const name = resolveAppName(arg)
if (!name) return
const stats: AppStats | undefined = await get(`/api/tools/stats/api/stats/${name}`)
if (!stats) {
const metrics: AppMetrics | undefined = await get(`/api/tools/metrics/api/metrics/${name}`)
if (!metrics) {
console.error(`App not found: ${name}`)
return
}
console.log(`${color.bold(stats.name)} ${stats.tool ? color.gray('[tool]') : ''}`)
console.log(` State: ${stats.state}`)
if (stats.pid) console.log(` PID: ${stats.pid}`)
if (stats.port) console.log(` Port: ${stats.port}`)
if (stats.cpu !== undefined) console.log(` CPU: ${formatPercent(stats.cpu)}`)
if (stats.memory !== undefined) console.log(` Memory: ${formatPercent(stats.memory)}`)
if (stats.rss !== undefined) console.log(` RSS: ${formatRss(stats.rss)}`)
console.log(`${color.bold(metrics.name)} ${metrics.tool ? color.gray('[tool]') : ''}`)
console.log(` State: ${metrics.state}`)
if (metrics.pid) console.log(` PID: ${metrics.pid}`)
if (metrics.port) console.log(` Port: ${metrics.port}`)
if (metrics.cpu !== undefined) console.log(` CPU: ${formatPercent(metrics.cpu)}`)
if (metrics.memory !== undefined) console.log(` Memory: ${formatPercent(metrics.memory)}`)
if (metrics.rss !== undefined) console.log(` RSS: ${formatRss(metrics.rss)}`)
console.log(` Data: ${formatBytes(metrics.dataSize)}`)
return
}
// Show stats for all apps
const stats: AppStats[] | undefined = await get('/api/tools/stats/api/stats')
if (!stats || stats.length === 0) {
// Show metrics for all apps
const metrics: AppMetrics[] | undefined = await get('/api/tools/metrics/api/metrics')
if (!metrics || metrics.length === 0) {
console.log('No apps found')
return
}
// Sort: running first, then by name
stats.sort((a, b) => {
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)
})
// Calculate column widths
const nameWidth = Math.max(4, ...stats.map(s => s.name.length + (s.tool ? 7 : 0)))
const stateWidth = Math.max(5, ...stats.map(s => s.state.length))
const nameWidth = Math.max(4, ...metrics.map(s => s.name.length + (s.tool ? 7 : 0)))
const stateWidth = Math.max(5, ...metrics.map(s => s.state.length))
// Header
console.log(
color.gray(
`${pad('NAME', nameWidth)} ${pad('STATE', stateWidth)} ${pad('PID', 7, true)} ${pad('CPU', 7, true)} ${pad('MEM', 7, true)} ${pad('RSS', 10, true)}`
`${pad('NAME', nameWidth)} ${pad('STATE', stateWidth)} ${pad('PID', 7, true)} ${pad('CPU', 7, true)} ${pad('MEM', 7, true)} ${pad('RSS', 10, true)} ${pad('DATA', 10, true)}`
)
)
// Rows
for (const s of stats) {
for (const s of metrics) {
const name = s.tool ? `${s.name} ${color.gray('[tool]')}` : s.name
const stateColor = s.state === 'running' ? color.green : s.state === 'invalid' ? color.red : color.gray
const state = stateColor(s.state)
@ -87,17 +97,19 @@ export async function statsApp(arg?: string) {
const cpu = formatPercent(s.cpu)
const mem = formatPercent(s.memory)
const rss = formatRss(s.rss)
const data = formatBytes(s.dataSize)
console.log(
`${pad(name, nameWidth)} ${pad(state, stateWidth)} ${pad(pid, 7, true)} ${pad(cpu, 7, true)} ${pad(mem, 7, true)} ${pad(rss, 10, true)}`
`${pad(name, nameWidth)} ${pad(state, stateWidth)} ${pad(pid, 7, true)} ${pad(cpu, 7, true)} ${pad(mem, 7, true)} ${pad(rss, 10, true)} ${pad(data, 10, true)}`
)
}
// Summary
const running = stats.filter(s => s.state === 'running')
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)
console.log()
console.log(color.gray(`${running.length} running, ${formatPercent(totalCpu)} CPU, ${formatRss(totalRss)} total`))
console.log(color.gray(`${running.length} running, ${formatPercent(totalCpu)} CPU, ${formatRss(totalRss)} RSS, ${formatBytes(totalData)} data`))
}

View File

@ -32,7 +32,7 @@ import {
stashListApp,
stashPopApp,
startApp,
statsApp,
metricsApp,
statusApp,
stopApp,
syncApp,
@ -161,11 +161,11 @@ program
.action(logApp)
program
.command('stats')
.command('metrics')
.helpGroup('Lifecycle:')
.description('Show CPU and memory stats for apps')
.description('Show CPU, memory, and disk metrics for apps')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(statsApp)
.action(metricsApp)
const cron = program
.command('cron')