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:
- **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,19 @@
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 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 TOES_DIR = process.env.TOES_DIR!
const TOES_URL = process.env.TOES_URL!
// ============================================================================
@ -24,6 +28,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 +42,36 @@ interface HistorySample {
rss: number
}
interface ProcessStats {
interface DataSample {
date: string
bytes: number
}
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 dataHistory = new Map<string, DataSample[]>() // app name -> daily data size
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 getDataHistory(appName: string): DataSample[] {
return dataHistory.get(appName) ?? []
}
function getProcessStats(pid: number): ProcessStats | undefined {
return statsCache.get(pid)
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 {
@ -102,27 +91,79 @@ 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)
}
}
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> {
await sampleProcessStats()
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 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)
}
}
}
// Record data size history for all apps (filesystem-based, not process-based)
for (const app of apps) {
recordDataSize(app.name)
}
} catch {
// Ignore errors
}
@ -132,6 +173,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 +210,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,
}
}
@ -272,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',
})
@ -310,11 +376,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 +389,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 +451,24 @@ 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/data-history/:name', c => {
const name = c.req.param('name')
const history = getDataHistory(name)
return c.json(history)
})
app.get('/api/history/:name', c => {
@ -403,18 +483,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 +503,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>
@ -468,6 +550,15 @@ app.get('/', async c => {
</NoDataMessage>
</ChartWrapper>
</ChartCard>
<ChartCard>
<ChartTitle>Data Size</ChartTitle>
<ChartWrapper>
<canvas id="dataChart"></canvas>
<NoDataMessage id="dataNoData" 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>
@ -478,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)';
@ -518,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);
@ -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() {
try {
const res = await fetch('/api/history/' + encodeURIComponent(appName));
@ -664,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
})();
`}} />
</Layout>
@ -674,29 +838,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 +871,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 +890,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')

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)
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 })

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 { 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 })

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)
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)