Merge pull request #10 from defunkt/claude/rename-stats-add-filesize-4nMTq
Rename stats to metrics and add disk usage tracking
This commit is contained in:
commit
bbdcefd1f7
|
|
@ -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`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} total
|
{running.length} running · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} RSS · {formatBytes(totalData)} data
|
||||||
</Summary>
|
</Summary>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "stats",
|
"name": "metrics",
|
||||||
"module": "index.tsx",
|
"module": "index.tsx",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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`))
|
||||||
}
|
}
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user