toes/apps/stats/20260130-000000/index.tsx
2026-02-04 13:50:26 -08:00

739 lines
21 KiB
TypeScript

import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import type { Child } from 'hono/jsx'
// ============================================================================
// Configuration
// ============================================================================
const SAMPLE_INTERVAL_MS = 10_000 // How often to sample process stats
const HISTORY_MAX_SAMPLES = 60 // Keep 10 minutes of history at 10s intervals
const TOES_URL = process.env.TOES_URL!
// ============================================================================
// Types
// ============================================================================
interface App {
name: string
state: string
port?: number
pid?: number
tool?: boolean
}
interface HistorySample {
timestamp: number
cpu: number
memory: number
rss: number
}
interface ProcessStats {
pid: number
cpu: number
memory: number
rss: number
}
interface AppStats extends App {
cpu?: number
memory?: number
rss?: number
}
// ============================================================================
// Process Stats Collection
// ============================================================================
const statsCache = new Map<number, ProcessStats>()
const appHistory = new Map<string, HistorySample[]>() // app name -> history
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 getProcessStats(pid: number): ProcessStats | undefined {
return statsCache.get(pid)
}
function recordHistory(appName: string, cpu: number, memory: number, rss: number): void {
const history = appHistory.get(appName) ?? []
history.push({
timestamp: Date.now(),
cpu,
memory,
rss,
})
// Keep only the last N samples
while (history.length > HISTORY_MAX_SAMPLES) {
history.shift()
}
appHistory.set(appName, history)
}
function getHistory(appName: string): HistorySample[] {
return appHistory.get(appName) ?? []
}
async function sampleAndRecordHistory(): Promise<void> {
await sampleProcessStats()
// 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[]
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)
}
}
}
} catch {
// Ignore errors
}
}
// Start sampling on module load
sampleAndRecordHistory()
setInterval(sampleAndRecordHistory, SAMPLE_INTERVAL_MS)
// ============================================================================
// API Client
// ============================================================================
async function fetchApps(): Promise<App[]> {
try {
const res = await fetch(`${TOES_URL}/api/apps`)
if (!res.ok) return []
return await res.json() as App[]
} catch {
return []
}
}
async function getAppStats(): Promise<AppStats[]> {
const apps = await fetchApps()
return apps.map(app => {
const stats = app.pid ? getProcessStats(app.pid) : undefined
return {
...app,
cpu: stats?.cpu,
memory: stats?.memory,
rss: stats?.rss,
}
})
}
async function getAppStatsByName(name: string): Promise<AppStats | 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
return {
...app,
cpu: stats?.cpu,
memory: stats?.memory,
rss: stats?.rss,
}
}
// ============================================================================
// Styled Components
// ============================================================================
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '900px',
margin: '0 auto',
color: theme('colors-text'),
})
const Table = define('Table', {
base: 'table',
width: '100%',
borderCollapse: 'collapse',
fontSize: '14px',
fontFamily: theme('fonts-mono'),
})
const Th = define('Th', {
base: 'th',
textAlign: 'left',
padding: '10px 12px',
borderBottom: `2px solid ${theme('colors-border')}`,
color: theme('colors-textMuted'),
fontSize: '12px',
fontWeight: 'normal',
textTransform: 'uppercase',
letterSpacing: '0.5px',
})
const ThRight = define('ThRight', {
base: 'th',
textAlign: 'right',
padding: '10px 12px',
borderBottom: `2px solid ${theme('colors-border')}`,
color: theme('colors-textMuted'),
fontSize: '12px',
fontWeight: 'normal',
textTransform: 'uppercase',
letterSpacing: '0.5px',
})
const Tr = define('Tr', {
base: 'tr',
states: {
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const Td = define('Td', {
base: 'td',
padding: '10px 12px',
borderBottom: `1px solid ${theme('colors-border')}`,
})
const TdRight = define('TdRight', {
base: 'td',
padding: '10px 12px',
borderBottom: `1px solid ${theme('colors-border')}`,
textAlign: 'right',
})
const StatusBadge = define('StatusBadge', {
base: 'span',
fontSize: '12px',
padding: '2px 6px',
borderRadius: theme('radius-md'),
})
const ToolBadge = define('ToolBadge', {
base: 'span',
fontSize: '11px',
marginLeft: '6px',
color: theme('colors-textMuted'),
})
const Summary = define('Summary', {
marginTop: '20px',
padding: '12px 15px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
fontSize: '13px',
color: theme('colors-textMuted'),
})
const EmptyState = define('EmptyState', {
padding: '40px 20px',
textAlign: 'center',
color: theme('colors-textMuted'),
})
const ChartsContainer = define('ChartsContainer', {
marginTop: '24px',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '20px',
})
const ChartCard = define('ChartCard', {
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
padding: '16px',
border: `1px solid ${theme('colors-border')}`,
})
const ChartTitle = define('ChartTitle', {
margin: '0 0 12px 0',
fontSize: '13px',
fontWeight: 600,
color: theme('colors-textMuted'),
textTransform: 'uppercase',
letterSpacing: '0.5px',
})
const ChartWrapper = define('ChartWrapper', {
position: 'relative',
height: '150px',
})
const NoDataMessage = define('NoDataMessage', {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: theme('colors-textMuted'),
fontSize: '13px',
})
// ============================================================================
// 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 formatPercent(value?: number): string {
if (value === undefined) return '-'
return `${value.toFixed(1)}%`
}
function getStatusColor(state: string): string {
switch (state) {
case 'running':
return theme('colors-statusRunning')
case 'stopped':
case 'stopping':
return theme('colors-statusStopped')
case 'invalid':
return theme('colors-error')
default:
return theme('colors-textMuted')
}
}
// ============================================================================
// Layout
// ============================================================================
interface LayoutProps {
title: string
children: Child
}
function Layout({ title, children }: LayoutProps) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<ToolScript />
<Container>
{children}
</Container>
</body>
</html>
)
}
// ============================================================================
// App
// ============================================================================
const app = new Hype({ prettyHTML: false })
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
// API endpoint for CLI
app.get('/api/stats', async c => {
const stats = await getAppStats()
return c.json(stats)
})
app.get('/api/stats/:name', async c => {
const name = c.req.param('name')
const stats = await getAppStatsByName(name)
if (!stats) {
return c.json({ error: 'App not found' }, 404)
}
return c.json(stats)
})
app.get('/api/history/:name', c => {
const name = c.req.param('name')
const history = getHistory(name)
return c.json(history)
})
// Web UI
app.get('/', async c => {
const appName = c.req.query('app')
// Single app view
if (appName) {
const stats = await getAppStatsByName(appName)
if (!stats) {
return c.html(
<Layout title="Stats">
<EmptyState>App not found: {appName}</EmptyState>
</Layout>
)
}
return c.html(
<Layout title="Stats">
<Table>
<thead>
<tr>
<Th>State</Th>
<ThRight>PID</ThRight>
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
</tr>
</thead>
<tbody>
<Tr>
<Td>
<StatusBadge style={`color: ${getStatusColor(stats.state)}`}>
{stats.state}
</StatusBadge>
</Td>
<TdRight>{stats.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(stats.cpu)}</TdRight>
<TdRight>{formatPercent(stats.memory)}</TdRight>
<TdRight>{formatRss(stats.rss)}</TdRight>
</Tr>
</tbody>
</Table>
<ChartsContainer>
<ChartCard>
<ChartTitle>CPU Usage</ChartTitle>
<ChartWrapper>
<canvas id="cpuChart"></canvas>
<NoDataMessage id="cpuNoData" style="display: none">
Collecting data...
</NoDataMessage>
</ChartWrapper>
</ChartCard>
<ChartCard>
<ChartTitle>Memory %</ChartTitle>
<ChartWrapper>
<canvas id="memChart"></canvas>
<NoDataMessage id="memNoData" style="display: none">
Collecting data...
</NoDataMessage>
</ChartWrapper>
</ChartCard>
<ChartCard>
<ChartTitle>RSS Memory</ChartTitle>
<ChartWrapper>
<canvas id="rssChart"></canvas>
<NoDataMessage id="rssNoData" 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>
<script dangerouslySetInnerHTML={{__html: `
(function() {
const appName = ${JSON.stringify(appName)};
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
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 memColor = 'rgb(168, 85, 247)';
const rssColor = 'rgb(34, 197, 94)';
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
}
},
scales: {
x: {
display: true,
grid: { color: gridColor },
ticks: {
color: textColor,
maxTicksLimit: 6,
callback: function(val, idx) {
const label = this.getLabelForValue(val);
return label;
}
}
},
y: {
display: true,
grid: { color: gridColor },
ticks: { color: textColor },
beginAtZero: true
}
},
elements: {
point: { radius: 0 },
line: { tension: 0.3, borderWidth: 2 }
}
};
let cpuChart, memChart, rssChart;
function formatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatRss(kb) {
if (kb < 1024) return kb + ' KB';
return (kb / 1024).toFixed(1) + ' MB';
}
function updateCharts(history) {
const labels = history.map(h => formatTime(h.timestamp));
const cpuData = history.map(h => h.cpu);
const memData = history.map(h => h.memory);
const rssData = history.map(h => h.rss / 1024); // Convert to MB
if (history.length === 0) {
document.getElementById('cpuChart').style.display = 'none';
document.getElementById('memChart').style.display = 'none';
document.getElementById('rssChart').style.display = 'none';
document.getElementById('cpuNoData').style.display = 'flex';
document.getElementById('memNoData').style.display = 'flex';
document.getElementById('rssNoData').style.display = 'flex';
return;
}
document.getElementById('cpuChart').style.display = 'block';
document.getElementById('memChart').style.display = 'block';
document.getElementById('rssChart').style.display = 'block';
document.getElementById('cpuNoData').style.display = 'none';
document.getElementById('memNoData').style.display = 'none';
document.getElementById('rssNoData').style.display = 'none';
if (!cpuChart) {
cpuChart = new Chart(document.getElementById('cpuChart'), {
type: 'line',
data: {
labels: labels,
datasets: [{
data: cpuData,
borderColor: cpuColor,
backgroundColor: cpuColor.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) + '%'
}
}
}
}
});
} else {
cpuChart.data.labels = labels;
cpuChart.data.datasets[0].data = cpuData;
cpuChart.update('none');
}
if (!memChart) {
memChart = new Chart(document.getElementById('memChart'), {
type: 'line',
data: {
labels: labels,
datasets: [{
data: memData,
borderColor: memColor,
backgroundColor: memColor.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) + '%'
}
}
}
}
});
} else {
memChart.data.labels = labels;
memChart.data.datasets[0].data = memData;
memChart.update('none');
}
if (!rssChart) {
rssChart = new Chart(document.getElementById('rssChart'), {
type: 'line',
data: {
labels: labels,
datasets: [{
data: rssData,
borderColor: rssColor,
backgroundColor: rssColor.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 {
rssChart.data.labels = labels;
rssChart.data.datasets[0].data = rssData;
rssChart.update('none');
}
}
async function fetchHistory() {
try {
const res = await fetch('/api/history/' + encodeURIComponent(appName));
if (res.ok) {
const history = await res.json();
updateCharts(history);
}
} catch (e) {
console.error('Failed to fetch history:', e);
}
}
// Initial fetch
fetchHistory();
// Update every 10 seconds
setInterval(fetchHistory, 10000);
})();
`}} />
</Layout>
)
}
// All apps view
const stats = await getAppStats()
// Sort: running first, then by name
stats.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) {
return c.html(
<Layout title="Stats">
<EmptyState>No apps found</EmptyState>
</Layout>
)
}
const running = stats.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)
return c.html(
<Layout title="Stats">
<Table>
<thead>
<tr>
<Th>Name</Th>
<Th>State</Th>
<ThRight>PID</ThRight>
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
</tr>
</thead>
<tbody>
{stats.map(s => (
<Tr>
<Td>
{s.name}
{s.tool && <ToolBadge>[tool]</ToolBadge>}
</Td>
<Td>
<StatusBadge style={`color: ${getStatusColor(s.state)}`}>
{s.state}
</StatusBadge>
</Td>
<TdRight>{s.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(s.cpu)}</TdRight>
<TdRight>{formatPercent(s.memory)}</TdRight>
<TdRight>{formatRss(s.rss)}</TdRight>
</Tr>
))}
</tbody>
</Table>
<Summary>
{running.length} running &middot; {formatPercent(totalCpu)} CPU &middot; {formatRss(totalRss)} total
</Summary>
</Layout>
)
})
export default app.defaults