From eb8ef0dd4d0700e7b3d1d0294693e9c142127956 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 15:28:20 +0000 Subject: [PATCH 1/4] 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 --- CLAUDE.md | 2 +- .../{stats => metrics}/20260130-000000/.npmrc | 0 .../20260130-000000/bun.lock | 0 .../20260130-000000/index.tsx | 214 +++++++++++------- .../20260130-000000/package.json | 2 +- .../20260130-000000/tsconfig.json | 0 src/cli/commands/index.ts | 2 +- src/cli/commands/{stats.ts => metrics.ts} | 58 +++-- src/cli/setup.ts | 8 +- 9 files changed, 170 insertions(+), 116 deletions(-) rename apps/{stats => metrics}/20260130-000000/.npmrc (100%) rename apps/{stats => metrics}/20260130-000000/bun.lock (100%) rename apps/{stats => metrics}/20260130-000000/index.tsx (84%) rename apps/{stats => metrics}/20260130-000000/package.json (95%) rename apps/{stats => metrics}/20260130-000000/tsconfig.json (100%) rename src/cli/commands/{stats.ts => metrics.ts} (52%) diff --git a/CLAUDE.md b/CLAUDE.md index 457808e..4d6ac86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/apps/stats/20260130-000000/.npmrc b/apps/metrics/20260130-000000/.npmrc similarity index 100% rename from apps/stats/20260130-000000/.npmrc rename to apps/metrics/20260130-000000/.npmrc diff --git a/apps/stats/20260130-000000/bun.lock b/apps/metrics/20260130-000000/bun.lock similarity index 100% rename from apps/stats/20260130-000000/bun.lock rename to apps/metrics/20260130-000000/bun.lock diff --git a/apps/stats/20260130-000000/index.tsx b/apps/metrics/20260130-000000/index.tsx similarity index 84% rename from apps/stats/20260130-000000/index.tsx rename to apps/metrics/20260130-000000/index.tsx index 7747730..a43be5d 100644 --- a/apps/stats/20260130-000000/index.tsx +++ b/apps/metrics/20260130-000000/index.tsx @@ -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() const appHistory = new Map() // app name -> history +const metricsCache = new Map() -async function sampleProcessStats(): 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 - - 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 { + 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 { - await sampleProcessStats() + await sampleProcessMetrics() // Record history for all running apps try { @@ -117,9 +121,9 @@ async function sampleAndRecordHistory(): Promise { 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 { 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 { } } -async function getAppStats(): Promise { +async function getAppMetrics(): Promise { 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 { +async function getAppMetricsByName(name: string): Promise { 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( - + App not found: {appName} ) } return c.html( - + @@ -423,19 +460,21 @@ app.get('/', async c => { CPUMEMRSS + Data - {stats.pid ?? '-'} - {formatPercent(stats.cpu)} - {formatPercent(stats.memory)} - {formatRss(stats.rss)} + {metrics.pid ?? '-'} + {formatPercent(metrics.cpu)} + {formatPercent(metrics.memory)} + {formatRss(metrics.rss)} + {formatBytes(metrics.dataSize)}
- - {stats.state} + + {metrics.state}
@@ -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( - + No apps found ) } - 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( - + @@ -706,10 +746,11 @@ app.get('/', async c => { CPUMEMRSS + Data - {stats.map(s => ( + {metrics.map(s => ( ))}
{s.name} @@ -724,12 +765,13 @@ app.get('/', async c => { {formatPercent(s.cpu)} {formatPercent(s.memory)} {formatRss(s.rss)} + {formatBytes(s.dataSize)}
- {running.length} running · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} total + {running.length} running · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} RSS · {formatBytes(totalData)} data
) diff --git a/apps/stats/20260130-000000/package.json b/apps/metrics/20260130-000000/package.json similarity index 95% rename from apps/stats/20260130-000000/package.json rename to apps/metrics/20260130-000000/package.json index 73ae360..e7ad3c8 100644 --- a/apps/stats/20260130-000000/package.json +++ b/apps/metrics/20260130-000000/package.json @@ -1,5 +1,5 @@ { - "name": "stats", + "name": "metrics", "module": "index.tsx", "type": "module", "private": true, diff --git a/apps/stats/20260130-000000/tsconfig.json b/apps/metrics/20260130-000000/tsconfig.json similarity index 100% rename from apps/stats/20260130-000000/tsconfig.json rename to apps/metrics/20260130-000000/tsconfig.json diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index ee2e0c5..4a434d4 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -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' diff --git a/src/cli/commands/stats.ts b/src/cli/commands/metrics.ts similarity index 52% rename from src/cli/commands/stats.ts rename to src/cli/commands/metrics.ts index 646c075..4a3c76b 100644 --- a/src/cli/commands/stats.ts +++ b/src/cli/commands/metrics.ts @@ -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`)) } diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 58d1e70..ead335d 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -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') From 2f4d609290b8fe6f7731156014782de3c681599a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 16:13:59 +0000 Subject: [PATCH 2/4] Fix app rename failing with "port is taken" error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renameApp() killed the old process with .kill() but didn't wait for it to actually exit before restarting on the same port. The OS still had the port bound, causing the new process to fail with "port is taken". Additionally, the old process's exit handler would fire after the rename and corrupt the app's state—releasing the new process's port, setting state to 'invalid', and nullifying the proc reference. Fix by: - Making renameApp async and awaiting proc.exited before proceeding - Guarding the exit handler to bail out when a newer process has taken over https://claude.ai/code/session_01W9GF8Cy7T6V2rnVcoNd1Nc --- src/server/api/apps.ts | 2 +- src/server/apps.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 6a3bdf8..51308bc 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -229,7 +229,7 @@ router.post('/:app/rename', async c => { if (!newName) return c.json({ ok: false, error: 'New name is required' }, 400) - const result = renameApp(appName, newName) + const result = await renameApp(appName, newName) if (!result.ok) return c.json(result, 400) return c.json({ ok: true, name: newName }) diff --git a/src/server/apps.ts b/src/server/apps.ts index b8096ee..95cb936 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -142,7 +142,7 @@ export function registerApp(dir: string) { } } -export function renameApp(oldName: string, newName: string): { ok: boolean, error?: string } { +export async function renameApp(oldName: string, newName: string): Promise<{ ok: boolean, error?: string }> { const app = _apps.get(oldName) if (!app) return { ok: false, error: 'App not found' } @@ -155,15 +155,13 @@ export function renameApp(oldName: string, newName: string): { ok: boolean, erro const oldPath = join(APPS_DIR, oldName) const newPath = join(APPS_DIR, newName) - // Stop the app if running + // Stop the app and wait for process to fully exit so the port is freed const wasRunning = app.state === 'running' if (wasRunning) { + const proc = app.proc clearTimers(app) app.proc?.kill() - app.proc = undefined - if (app.port) releasePort(app.port) - app.port = undefined - app.started = undefined + if (proc) await proc.exited } try { @@ -681,6 +679,10 @@ async function runApp(dir: string, port: number) { // Handle process exit proc.exited.then(code => { + // If the app has moved on (e.g. renamed and restarted), this is a + // stale exit handler — don't touch current app state or ports + if (app.proc && app.proc !== proc) return + // Clear all timers clearTimers(app) From a7d4e210c24200729d59606475e85d28e08b19fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 16:17:45 +0000 Subject: [PATCH 3/4] Auto-start stopped/errored apps on push activate Previously, pushing a new version would only restart apps that were already running. Apps in stopped or invalid state (e.g. due to a previous startup error) were left unchanged, requiring a manual start. Now the activate endpoint calls startApp() for stopped/invalid apps, so pushing a code fix automatically attempts to start the app. https://claude.ai/code/session_014UvBEvHbnhaoMLebdRFzm6 --- src/server/api/sync.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/api/sync.ts b/src/server/api/sync.ts index 2689503..2d99a44 100644 --- a/src/server/api/sync.ts +++ b/src/server/api/sync.ts @@ -1,4 +1,4 @@ -import { APPS_DIR, allApps, registerApp, removeApp, restartApp } from '$apps' +import { APPS_DIR, allApps, registerApp, removeApp, restartApp, startApp } from '$apps' import { computeHash, generateManifest } from '../sync' import { loadGitignore } from '@gitignore' import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, watch, writeFileSync } from 'fs' @@ -329,6 +329,9 @@ router.post('/apps/:app/activate', async c => { } catch (e) { return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500) } + } else if (app.state === 'stopped' || app.state === 'invalid') { + // App not running (possibly due to error) - try to start it + startApp(appName) } return c.json({ ok: true }) From 7a79133d784d2b2ce51d5ba04737dbf104ca6dbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 16:20:42 +0000 Subject: [PATCH 4/4] Add DATA chart with daily X axis and show 2 charts per row Track data size history daily (up to 30 days) in memory, with a dedicated /api/data-history/:name endpoint. The Data Size chart uses day labels (e.g. "Feb 12") instead of minute-based timestamps. Charts are now displayed in a 2-column grid. https://claude.ai/code/session_013agP8J1cCfrWZkueZ33jQB --- apps/metrics/20260130-000000/index.tsx | 131 ++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/apps/metrics/20260130-000000/index.tsx b/apps/metrics/20260130-000000/index.tsx index a43be5d..152dbcb 100644 --- a/apps/metrics/20260130-000000/index.tsx +++ b/apps/metrics/20260130-000000/index.tsx @@ -9,6 +9,7 @@ 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 @@ -41,6 +42,11 @@ interface HistorySample { rss: number } +interface DataSample { + date: string + bytes: number +} + interface ProcessMetrics { pid: number cpu: number @@ -53,8 +59,13 @@ interface ProcessMetrics { // ============================================================================ 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) ?? [] } @@ -110,15 +121,36 @@ async function sampleProcessMetrics(): Promise { } } +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() - // 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[] + // Record process history for running apps for (const app of apps) { if (app.pid && app.state === 'running') { const metrics = getProcessMetrics(app.pid) @@ -127,6 +159,11 @@ async function sampleAndRecordHistory(): Promise { } } } + + // Record data size history for all apps (filesystem-based, not process-based) + for (const app of apps) { + recordDataSize(app.name) + } } catch { // Ignore errors } @@ -301,7 +338,7 @@ const EmptyState = define('EmptyState', { const ChartsContainer = define('ChartsContainer', { marginTop: '24px', display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', + gridTemplateColumns: 'repeat(2, 1fr)', gap: '20px', }) @@ -428,6 +465,12 @@ app.get('/api/metrics/:name', async c => { 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) @@ -507,6 +550,15 @@ app.get('/', async c => { + + Data Size + + + + + @@ -517,6 +569,7 @@ app.get('/', async c => { 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 dataColor = 'rgb(245, 158, 11)'; const memColor = 'rgb(168, 85, 247)'; const rssColor = 'rgb(34, 197, 94)'; @@ -557,18 +610,76 @@ app.get('/', async c => { } }; - let cpuChart, memChart, rssChart; + let cpuChart, dataChart, memChart, rssChart; function formatTime(ts) { const d = new Date(ts); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } + function formatBytes(bytes) { + 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 formatDate(dateStr) { + const d = new Date(dateStr + 'T00:00:00'); + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } + function formatRss(kb) { if (kb < 1024) return kb + ' KB'; return (kb / 1024).toFixed(1) + ' MB'; } + function updateDataChart(history) { + if (history.length === 0) { + document.getElementById('dataChart').style.display = 'none'; + document.getElementById('dataNoData').style.display = 'flex'; + return; + } + + document.getElementById('dataChart').style.display = 'block'; + document.getElementById('dataNoData').style.display = 'none'; + + const labels = history.map(h => formatDate(h.date)); + const data = history.map(h => h.bytes / (1024 * 1024)); // Convert to MB + + if (!dataChart) { + dataChart = new Chart(document.getElementById('dataChart'), { + type: 'line', + data: { + labels: labels, + datasets: [{ + data: data, + borderColor: dataColor, + backgroundColor: dataColor.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 { + dataChart.data.labels = labels; + dataChart.data.datasets[0].data = data; + dataChart.update('none'); + } + } + function updateCharts(history) { const labels = history.map(h => formatTime(h.timestamp)); const cpuData = history.map(h => h.cpu); @@ -689,6 +800,18 @@ app.get('/', async c => { } } + async function fetchDataHistory() { + try { + const res = await fetch('/api/data-history/' + encodeURIComponent(appName)); + if (res.ok) { + const history = await res.json(); + updateDataChart(history); + } + } catch (e) { + console.error('Failed to fetch data history:', e); + } + } + async function fetchHistory() { try { const res = await fetch('/api/history/' + encodeURIComponent(appName)); @@ -703,9 +826,11 @@ app.get('/', async c => { // Initial fetch fetchHistory(); + fetchDataHistory(); // Update every 10 seconds setInterval(fetchHistory, 10000); + setInterval(fetchDataHistory, 60000); // Data size changes slowly })(); `}} />