Compare commits

...

8 Commits

Author SHA1 Message Date
512d9fe96b Merge remote-tracking branch 'github/main' 2026-02-12 12:16:50 -08:00
Chris Wanstrath
ee9c4a1d0a
Merge pull request #12 from defunkt/claude/auto-start-app-on-push-zklQA
Add app startup handling in activate endpoint
2026-02-12 08:46:10 -08:00
Chris Wanstrath
bbdcefd1f7
Merge pull request #10 from defunkt/claude/rename-stats-add-filesize-4nMTq
Rename stats to metrics and add disk usage tracking
2026-02-12 08:43:20 -08:00
Claude
7a79133d78
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
2026-02-12 16:20:42 +00:00
Claude
a7d4e210c2
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
2026-02-12 16:17:45 +00:00
Chris Wanstrath
f0bef491a6
Merge pull request #11 from defunkt/claude/fix-app-rename-shutdown-IzD79
Make renameApp async and fix race condition on app restart
2026-02-12 08:15:35 -08:00
Claude
2f4d609290
Fix app rename failing with "port is taken" error
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
2026-02-12 16:13:59 +00:00
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
12 changed files with 311 additions and 127 deletions

View File

@ -79,7 +79,7 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
CLI commands: CLI commands:
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm` - **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` - **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash`
- **Config**: `config`, `env`, `versions`, `history`, `rollback` - **Config**: `config`, `env`, `versions`, `history`, `rollback`

View File

@ -1,15 +1,19 @@
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge' import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools' import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { existsSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
import type { Child } from 'hono/jsx' import type { Child } from 'hono/jsx'
// ============================================================================ // ============================================================================
// Configuration // Configuration
// ============================================================================ // ============================================================================
const SAMPLE_INTERVAL_MS = 10_000 // How often to sample process stats 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 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! const TOES_URL = process.env.TOES_URL!
// ============================================================================ // ============================================================================
@ -24,6 +28,13 @@ interface App {
tool?: boolean tool?: boolean
} }
interface AppMetrics extends App {
cpu?: number
dataSize?: number
memory?: number
rss?: number
}
interface HistorySample { interface HistorySample {
timestamp: number timestamp: number
cpu: number cpu: number
@ -31,58 +42,36 @@ interface HistorySample {
rss: number rss: number
} }
interface ProcessStats { interface DataSample {
date: string
bytes: number
}
interface ProcessMetrics {
pid: number pid: number
cpu: number cpu: number
memory: number memory: number
rss: 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 appHistory = new Map<string, HistorySample[]>() // app name -> history
const dataHistory = new Map<string, DataSample[]>() // app name -> daily data size
const metricsCache = new Map<number, ProcessMetrics>()
async function sampleProcessStats(): Promise<void> { function getDataHistory(appName: string): DataSample[] {
try { return dataHistory.get(appName) ?? []
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 getProcessStats(pid: number): ProcessStats | undefined { function getHistory(appName: string): HistorySample[] {
return statsCache.get(pid) 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 { function recordHistory(appName: string, cpu: number, memory: number, rss: number): void {
@ -102,27 +91,79 @@ function recordHistory(appName: string, cpu: number, memory: number, rss: number
appHistory.set(appName, history) appHistory.set(appName, history)
} }
function getHistory(appName: string): HistorySample[] { async function sampleProcessMetrics(): Promise<void> {
return appHistory.get(appName) ?? [] 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<void> { async function sampleAndRecordHistory(): Promise<void> {
await sampleProcessStats() await sampleProcessMetrics()
// Record history for all running apps
try { try {
const res = await fetch(`${TOES_URL}/api/apps`) const res = await fetch(`${TOES_URL}/api/apps`)
if (!res.ok) return if (!res.ok) return
const apps = await res.json() as App[] const apps = await res.json() as App[]
// Record process history for running apps
for (const app of apps) { for (const app of apps) {
if (app.pid && app.state === 'running') { if (app.pid && app.state === 'running') {
const stats = getProcessStats(app.pid) const metrics = getProcessMetrics(app.pid)
if (stats) { if (metrics) {
recordHistory(app.name, stats.cpu, stats.memory, stats.rss) 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 { } catch {
// Ignore errors // Ignore errors
} }
@ -132,6 +173,29 @@ async function sampleAndRecordHistory(): Promise<void> {
sampleAndRecordHistory() sampleAndRecordHistory()
setInterval(sampleAndRecordHistory, SAMPLE_INTERVAL_MS) 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 // API Client
// ============================================================================ // ============================================================================
@ -146,30 +210,32 @@ async function fetchApps(): Promise<App[]> {
} }
} }
async function getAppStats(): Promise<AppStats[]> { async function getAppMetrics(): Promise<AppMetrics[]> {
const apps = await fetchApps() const apps = await fetchApps()
return apps.map(app => { return apps.map(app => {
const stats = app.pid ? getProcessStats(app.pid) : undefined const metrics = app.pid ? getProcessMetrics(app.pid) : undefined
return { return {
...app, ...app,
cpu: stats?.cpu, cpu: metrics?.cpu,
memory: stats?.memory, dataSize: getDataSize(app.name),
rss: stats?.rss, 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 apps = await fetchApps()
const app = apps.find(a => a.name === name) const app = apps.find(a => a.name === name)
if (!app) return undefined if (!app) return undefined
const stats = app.pid ? getProcessStats(app.pid) : undefined const metrics = app.pid ? getProcessMetrics(app.pid) : undefined
return { return {
...app, ...app,
cpu: stats?.cpu, cpu: metrics?.cpu,
memory: stats?.memory, dataSize: getDataSize(app.name),
rss: stats?.rss, memory: metrics?.memory,
rss: metrics?.rss,
} }
} }
@ -272,7 +338,7 @@ const EmptyState = define('EmptyState', {
const ChartsContainer = define('ChartsContainer', { const ChartsContainer = define('ChartsContainer', {
marginTop: '24px', marginTop: '24px',
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gridTemplateColumns: 'repeat(2, 1fr)',
gap: '20px', gap: '20px',
}) })
@ -310,11 +376,12 @@ const NoDataMessage = define('NoDataMessage', {
// Helpers // Helpers
// ============================================================================ // ============================================================================
function formatRss(kb?: number): string { function formatBytes(bytes?: number): string {
if (kb === undefined) return '-' if (bytes === undefined) return '-'
if (kb < 1024) return `${kb} KB` if (bytes < 1024) return `${bytes} B`
if (kb < 1024 * 1024) return `${(kb / 1024).toFixed(1)} MB` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(kb / 1024 / 1024).toFixed(2)} GB` 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 { function formatPercent(value?: number): string {
@ -322,6 +389,13 @@ function formatPercent(value?: number): string {
return `${value.toFixed(1)}%` 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 { function getStatusColor(state: string): string {
switch (state) { switch (state) {
case 'running': case 'running':
@ -377,18 +451,24 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
})) }))
// API endpoint for CLI // API endpoint for CLI
app.get('/api/stats', async c => { app.get('/api/metrics', async c => {
const stats = await getAppStats() const metrics = await getAppMetrics()
return c.json(stats) 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 name = c.req.param('name')
const stats = await getAppStatsByName(name) const metrics = await getAppMetricsByName(name)
if (!stats) { if (!metrics) {
return c.json({ error: 'App not found' }, 404) return c.json({ error: 'App not found' }, 404)
} }
return c.json(stats) 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 => { app.get('/api/history/:name', c => {
@ -403,18 +483,18 @@ app.get('/', async c => {
// Single app view // Single app view
if (appName) { if (appName) {
const stats = await getAppStatsByName(appName) const metrics = await getAppMetricsByName(appName)
if (!stats) { if (!metrics) {
return c.html( return c.html(
<Layout title="Stats"> <Layout title="Metrics">
<EmptyState>App not found: {appName}</EmptyState> <EmptyState>App not found: {appName}</EmptyState>
</Layout> </Layout>
) )
} }
return c.html( return c.html(
<Layout title="Stats"> <Layout title="Metrics">
<Table> <Table>
<thead> <thead>
<tr> <tr>
@ -423,19 +503,21 @@ app.get('/', async c => {
<ThRight>CPU</ThRight> <ThRight>CPU</ThRight>
<ThRight>MEM</ThRight> <ThRight>MEM</ThRight>
<ThRight>RSS</ThRight> <ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<Tr> <Tr>
<Td> <Td>
<StatusBadge style={`color: ${getStatusColor(stats.state)}`}> <StatusBadge style={`color: ${getStatusColor(metrics.state)}`}>
{stats.state} {metrics.state}
</StatusBadge> </StatusBadge>
</Td> </Td>
<TdRight>{stats.pid ?? '-'}</TdRight> <TdRight>{metrics.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(stats.cpu)}</TdRight> <TdRight>{formatPercent(metrics.cpu)}</TdRight>
<TdRight>{formatPercent(stats.memory)}</TdRight> <TdRight>{formatPercent(metrics.memory)}</TdRight>
<TdRight>{formatRss(stats.rss)}</TdRight> <TdRight>{formatRss(metrics.rss)}</TdRight>
<TdRight>{formatBytes(metrics.dataSize)}</TdRight>
</Tr> </Tr>
</tbody> </tbody>
</Table> </Table>
@ -468,6 +550,15 @@ app.get('/', async c => {
</NoDataMessage> </NoDataMessage>
</ChartWrapper> </ChartWrapper>
</ChartCard> </ChartCard>
<ChartCard>
<ChartTitle>Data Size</ChartTitle>
<ChartWrapper>
<canvas id="dataChart"></canvas>
<NoDataMessage id="dataNoData" style="display: none">
Collecting data...
</NoDataMessage>
</ChartWrapper>
</ChartCard>
</ChartsContainer> </ChartsContainer>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
@ -478,6 +569,7 @@ app.get('/', async c => {
const textColor = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'; 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 gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
const cpuColor = 'rgb(59, 130, 246)'; const cpuColor = 'rgb(59, 130, 246)';
const dataColor = 'rgb(245, 158, 11)';
const memColor = 'rgb(168, 85, 247)'; const memColor = 'rgb(168, 85, 247)';
const rssColor = 'rgb(34, 197, 94)'; const rssColor = 'rgb(34, 197, 94)';
@ -518,18 +610,76 @@ app.get('/', async c => {
} }
}; };
let cpuChart, memChart, rssChart; let cpuChart, dataChart, memChart, rssChart;
function formatTime(ts) { function formatTime(ts) {
const d = new Date(ts); const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 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) { function formatRss(kb) {
if (kb < 1024) return kb + ' KB'; if (kb < 1024) return kb + ' KB';
return (kb / 1024).toFixed(1) + ' MB'; 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) { function updateCharts(history) {
const labels = history.map(h => formatTime(h.timestamp)); const labels = history.map(h => formatTime(h.timestamp));
const cpuData = history.map(h => h.cpu); const cpuData = history.map(h => h.cpu);
@ -650,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() { async function fetchHistory() {
try { try {
const res = await fetch('/api/history/' + encodeURIComponent(appName)); const res = await fetch('/api/history/' + encodeURIComponent(appName));
@ -664,9 +826,11 @@ app.get('/', async c => {
// Initial fetch // Initial fetch
fetchHistory(); fetchHistory();
fetchDataHistory();
// Update every 10 seconds // Update every 10 seconds
setInterval(fetchHistory, 10000); setInterval(fetchHistory, 10000);
setInterval(fetchDataHistory, 60000); // Data size changes slowly
})(); })();
`}} /> `}} />
</Layout> </Layout>
@ -674,29 +838,30 @@ app.get('/', async c => {
} }
// All apps view // All apps view
const stats = await getAppStats() const metrics = await getAppMetrics()
// Sort: running first, then by name // 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
if (a.state !== 'running' && b.state === 'running') return 1 if (a.state !== 'running' && b.state === 'running') return 1
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
if (stats.length === 0) { if (metrics.length === 0) {
return c.html( return c.html(
<Layout title="Stats"> <Layout title="Metrics">
<EmptyState>No apps found</EmptyState> <EmptyState>No apps found</EmptyState>
</Layout> </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 totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 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( return c.html(
<Layout title="Stats"> <Layout title="Metrics">
<Table> <Table>
<thead> <thead>
<tr> <tr>
@ -706,10 +871,11 @@ app.get('/', async c => {
<ThRight>CPU</ThRight> <ThRight>CPU</ThRight>
<ThRight>MEM</ThRight> <ThRight>MEM</ThRight>
<ThRight>RSS</ThRight> <ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{stats.map(s => ( {metrics.map(s => (
<Tr> <Tr>
<Td> <Td>
{s.name} {s.name}
@ -724,12 +890,13 @@ app.get('/', async c => {
<TdRight>{formatPercent(s.cpu)}</TdRight> <TdRight>{formatPercent(s.cpu)}</TdRight>
<TdRight>{formatPercent(s.memory)}</TdRight> <TdRight>{formatPercent(s.memory)}</TdRight>
<TdRight>{formatRss(s.rss)}</TdRight> <TdRight>{formatRss(s.rss)}</TdRight>
<TdRight>{formatBytes(s.dataSize)}</TdRight>
</Tr> </Tr>
))} ))}
</tbody> </tbody>
</Table> </Table>
<Summary> <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> </Summary>
</Layout> </Layout>
) )

View File

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

View File

@ -13,5 +13,5 @@ export {
startApp, startApp,
stopApp, stopApp,
} from './manage' } 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' 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 { get } from '../http'
import { resolveAppName } from '../name' import { resolveAppName } from '../name'
interface AppStats { interface AppMetrics {
name: string name: string
state: string state: string
port?: number port?: number
@ -10,9 +10,18 @@ interface AppStats {
cpu?: number cpu?: number
memory?: number memory?: number
rss?: number rss?: number
dataSize?: number
tool?: boolean 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 { function formatRss(kb?: number): string {
if (kb === undefined) return '-' if (kb === undefined) return '-'
if (kb < 1024) return `${kb} KB` if (kb < 1024) return `${kb} KB`
@ -30,55 +39,56 @@ function pad(str: string, len: number, right = false): string {
return str.padEnd(len) return str.padEnd(len)
} }
export async function statsApp(arg?: string) { export async function metricsApp(arg?: string) {
// If arg is provided, show stats for that app only // If arg is provided, show metrics for that app only
if (arg) { if (arg) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return if (!name) return
const stats: AppStats | undefined = await get(`/api/tools/stats/api/stats/${name}`) const metrics: AppMetrics | undefined = await get(`/api/tools/metrics/api/metrics/${name}`)
if (!stats) { if (!metrics) {
console.error(`App not found: ${name}`) console.error(`App not found: ${name}`)
return return
} }
console.log(`${color.bold(stats.name)} ${stats.tool ? color.gray('[tool]') : ''}`) console.log(`${color.bold(metrics.name)} ${metrics.tool ? color.gray('[tool]') : ''}`)
console.log(` State: ${stats.state}`) console.log(` State: ${metrics.state}`)
if (stats.pid) console.log(` PID: ${stats.pid}`) if (metrics.pid) console.log(` PID: ${metrics.pid}`)
if (stats.port) console.log(` Port: ${stats.port}`) if (metrics.port) console.log(` Port: ${metrics.port}`)
if (stats.cpu !== undefined) console.log(` CPU: ${formatPercent(stats.cpu)}`) if (metrics.cpu !== undefined) console.log(` CPU: ${formatPercent(metrics.cpu)}`)
if (stats.memory !== undefined) console.log(` Memory: ${formatPercent(stats.memory)}`) if (metrics.memory !== undefined) console.log(` Memory: ${formatPercent(metrics.memory)}`)
if (stats.rss !== undefined) console.log(` RSS: ${formatRss(stats.rss)}`) if (metrics.rss !== undefined) console.log(` RSS: ${formatRss(metrics.rss)}`)
console.log(` Data: ${formatBytes(metrics.dataSize)}`)
return return
} }
// Show stats for all apps // Show metrics for all apps
const stats: AppStats[] | undefined = await get('/api/tools/stats/api/stats') const metrics: AppMetrics[] | undefined = await get('/api/tools/metrics/api/metrics')
if (!stats || stats.length === 0) { if (!metrics || metrics.length === 0) {
console.log('No apps found') console.log('No apps found')
return return
} }
// Sort: running first, then by name // 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
if (a.state !== 'running' && b.state === 'running') return 1 if (a.state !== 'running' && b.state === 'running') return 1
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
// Calculate column widths // Calculate column widths
const nameWidth = Math.max(4, ...stats.map(s => s.name.length + (s.tool ? 7 : 0))) const nameWidth = Math.max(4, ...metrics.map(s => s.name.length + (s.tool ? 7 : 0)))
const stateWidth = Math.max(5, ...stats.map(s => s.state.length)) const stateWidth = Math.max(5, ...metrics.map(s => s.state.length))
// Header // Header
console.log( console.log(
color.gray( 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 // Rows
for (const s of stats) { for (const s of metrics) {
const name = s.tool ? `${s.name} ${color.gray('[tool]')}` : s.name 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 stateColor = s.state === 'running' ? color.green : s.state === 'invalid' ? color.red : color.gray
const state = stateColor(s.state) const state = stateColor(s.state)
@ -87,17 +97,19 @@ export async function statsApp(arg?: string) {
const cpu = formatPercent(s.cpu) const cpu = formatPercent(s.cpu)
const mem = formatPercent(s.memory) const mem = formatPercent(s.memory)
const rss = formatRss(s.rss) const rss = formatRss(s.rss)
const data = formatBytes(s.dataSize)
console.log( 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 // 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 totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 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()
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, stashListApp,
stashPopApp, stashPopApp,
startApp, startApp,
statsApp, metricsApp,
statusApp, statusApp,
stopApp, stopApp,
syncApp, syncApp,
@ -161,11 +161,11 @@ program
.action(logApp) .action(logApp)
program program
.command('stats') .command('metrics')
.helpGroup('Lifecycle:') .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)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(statsApp) .action(metricsApp)
const cron = program const cron = program
.command('cron') .command('cron')

View File

@ -229,7 +229,7 @@ router.post('/:app/rename', async c => {
if (!newName) return c.json({ ok: false, error: 'New name is required' }, 400) 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) if (!result.ok) return c.json(result, 400)
return c.json({ ok: true, name: newName }) return c.json({ ok: true, name: newName })

View File

@ -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 { computeHash, generateManifest } from '../sync'
import { loadGitignore } from '@gitignore' import { loadGitignore } from '@gitignore'
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, watch, writeFileSync } from 'fs' 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) { } catch (e) {
return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500) 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 }) return c.json({ ok: true })

View File

@ -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) const app = _apps.get(oldName)
if (!app) return { ok: false, error: 'App not found' } 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 oldPath = join(APPS_DIR, oldName)
const newPath = join(APPS_DIR, newName) 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' const wasRunning = app.state === 'running'
if (wasRunning) { if (wasRunning) {
const proc = app.proc
clearTimers(app) clearTimers(app)
app.proc?.kill() app.proc?.kill()
app.proc = undefined if (proc) await proc.exited
if (app.port) releasePort(app.port)
app.port = undefined
app.started = undefined
} }
try { try {
@ -681,6 +679,10 @@ async function runApp(dir: string, port: number) {
// Handle process exit // Handle process exit
proc.exited.then(code => { 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 // Clear all timers
clearTimers(app) clearTimers(app)