Compare commits
3 Commits
9bf3973020
...
8f91b676e9
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f91b676e9 | |||
| a396f740a5 | |||
| 02fca1313c |
15
apps/env/20260130-000000/index.tsx
vendored
15
apps/env/20260130-000000/index.tsx
vendored
|
|
@ -19,18 +19,6 @@ const Container = define('Container', {
|
|||
color: theme('colors-text'),
|
||||
})
|
||||
|
||||
const Header = define('Header', {
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '10px',
|
||||
borderBottom: `2px solid ${theme('colors-border')}`,
|
||||
})
|
||||
|
||||
const Title = define('Title', {
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
})
|
||||
|
||||
const EnvList = define('EnvList', {
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
|
|
@ -170,9 +158,6 @@ function Layout({ title, children }: LayoutProps) {
|
|||
<body>
|
||||
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>Environment Variables</Title>
|
||||
</Header>
|
||||
{children}
|
||||
</Container>
|
||||
<script dangerouslySetInnerHTML={{ __html: clientScript }} />
|
||||
|
|
|
|||
1
apps/stats/20260130-000000/.npmrc
Normal file
1
apps/stats/20260130-000000/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
registry=https://npm.nose.space
|
||||
45
apps/stats/20260130-000000/bun.lock
Normal file
45
apps/stats/20260130-000000/bun.lock
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "stats",
|
||||
"dependencies": {
|
||||
"@because/forge": "^0.0.1",
|
||||
"@because/hype": "^0.0.2",
|
||||
"@because/toes": "^0.0.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
||||
|
||||
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
||||
|
||||
"@because/toes": ["@because/toes@0.0.5", "https://npm.nose.space/@because/toes/-/toes-0.0.5.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-YM1VuR1sym7m7pFcaiqnjg6eJUyhJYUH2ROBb+xi+HEXajq46ZL8KDyyCtz7WiHTfrbxcEWGjqyj20a7UppcJg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
738
apps/stats/20260130-000000/index.tsx
Normal file
738
apps/stats/20260130-000000/index.tsx
Normal file
|
|
@ -0,0 +1,738 @@
|
|||
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 · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} total
|
||||
</Summary>
|
||||
</Layout>
|
||||
)
|
||||
})
|
||||
|
||||
export default app.defaults
|
||||
26
apps/stats/20260130-000000/package.json
Normal file
26
apps/stats/20260130-000000/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "stats",
|
||||
"module": "index.tsx",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"toes": "bun run --watch index.tsx",
|
||||
"start": "bun toes",
|
||||
"dev": "bun run --hot index.tsx"
|
||||
},
|
||||
"toes": {
|
||||
"tool": true,
|
||||
"icon": "📊"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@because/forge": "^0.0.1",
|
||||
"@because/hype": "^0.0.2",
|
||||
"@because/toes": "^0.0.5"
|
||||
}
|
||||
}
|
||||
23
apps/stats/20260130-000000/tsconfig.json
Normal file
23
apps/stats/20260130-000000/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,8 @@ app.get("/", (c) => {
|
|||
|
||||
app.get("/txt", c => c.text(truism()))
|
||||
|
||||
app.get("/ok", c => c.text("ok"))
|
||||
|
||||
export default {
|
||||
port: process.env.PORT || 3000,
|
||||
fetch: app.fetch,
|
||||
|
|
|
|||
|
|
@ -19,24 +19,6 @@ const Container = define('Container', {
|
|||
color: theme('colors-text'),
|
||||
})
|
||||
|
||||
const Header = define('Header', {
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '10px',
|
||||
borderBottom: `2px solid ${theme('colors-border')}`,
|
||||
})
|
||||
|
||||
const Title = define('Title', {
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
})
|
||||
|
||||
const Subtitle = define('Subtitle', {
|
||||
color: theme('colors-textMuted'),
|
||||
fontSize: '18px',
|
||||
marginTop: '5px',
|
||||
})
|
||||
|
||||
const VersionList = define('VersionList', {
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
|
|
@ -95,11 +77,10 @@ const ErrorBox = define('ErrorBox', {
|
|||
|
||||
interface LayoutProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: Child
|
||||
}
|
||||
|
||||
function Layout({ title, subtitle, children }: LayoutProps) {
|
||||
function Layout({ title, children }: LayoutProps) {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
|
|
@ -111,9 +92,6 @@ function Layout({ title, subtitle, children }: LayoutProps) {
|
|||
<body>
|
||||
<ToolScript />
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>Versions</Title>
|
||||
</Header>
|
||||
{children}
|
||||
</Container>
|
||||
</body>
|
||||
|
|
@ -172,14 +150,14 @@ app.get('/', async c => {
|
|||
|
||||
if (versions.length === 0) {
|
||||
return c.html(
|
||||
<Layout title="Versions" subtitle={appName}>
|
||||
<Layout title="Versions">
|
||||
<ErrorBox>No versions found</ErrorBox>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return c.html(
|
||||
<Layout title="Versions" subtitle={appName}>
|
||||
<Layout title="Versions">
|
||||
<VersionList>
|
||||
{versions.map(v => (
|
||||
<VersionItem>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ export async function infoApp(arg?: string) {
|
|||
console.log(` Port: ${app.port}`)
|
||||
console.log(` URL: ${makeAppUrl(app.port)}`)
|
||||
}
|
||||
if (app.pid) {
|
||||
console.log(` PID: ${app.pid}`)
|
||||
}
|
||||
if (app.started) {
|
||||
const uptime = Date.now() - app.started
|
||||
const seconds = Math.floor(uptime / 1000) % 60
|
||||
|
|
|
|||
|
|
@ -89,6 +89,14 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
|||
</InfoValue>
|
||||
</InfoRow>
|
||||
)}
|
||||
{app.state === 'running' && app.pid && (
|
||||
<InfoRow>
|
||||
<InfoLabel>PID</InfoLabel>
|
||||
<InfoValue>
|
||||
{app.pid}
|
||||
</InfoValue>
|
||||
</InfoRow>
|
||||
)}
|
||||
{app.started && (
|
||||
<InfoRow>
|
||||
<InfoLabel>Started</InfoLabel>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ let _shuttingDown = false
|
|||
export type App = SharedApp & {
|
||||
consecutiveHealthFailures?: number
|
||||
healthCheckTimer?: Timer
|
||||
isHttpApp?: boolean
|
||||
lastRestartTime?: number
|
||||
manuallyStopped?: boolean
|
||||
proc?: Subprocess
|
||||
|
|
@ -184,11 +185,12 @@ export function renameApp(oldName: string, newName: string): { ok: boolean, erro
|
|||
|
||||
export function startApp(dir: string) {
|
||||
const app = _apps.get(dir)
|
||||
if (!app || app.state !== 'stopped') return
|
||||
if (!app || (app.state !== 'stopped' && app.state !== 'invalid')) return
|
||||
if (!isApp(dir)) return
|
||||
|
||||
// Clear manually stopped flag when explicitly starting
|
||||
// Clear flags when explicitly starting
|
||||
app.manuallyStopped = false
|
||||
app.error = undefined
|
||||
runApp(dir, getPort(dir))
|
||||
}
|
||||
|
||||
|
|
@ -448,6 +450,23 @@ function initPortPool() {
|
|||
}
|
||||
}
|
||||
|
||||
function markAsRunning(app: App, port: number, isHttpApp: boolean) {
|
||||
if (app.startupTimer) {
|
||||
clearTimeout(app.startupTimer)
|
||||
app.startupTimer = undefined
|
||||
}
|
||||
app.state = 'running'
|
||||
app.started = Date.now()
|
||||
app.isHttpApp = isHttpApp
|
||||
update()
|
||||
|
||||
if (isHttpApp) {
|
||||
startHealthChecks(app, port)
|
||||
} else {
|
||||
startProcessHealthChecks(app)
|
||||
}
|
||||
}
|
||||
|
||||
function loadApp(dir: string): LoadResult {
|
||||
try {
|
||||
const pkgPath = join(APPS_DIR, dir, 'current', 'package.json')
|
||||
|
|
@ -582,19 +601,71 @@ async function runApp(dir: string, port: number) {
|
|||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
// Clear startup timer and set state to running
|
||||
if (app.startupTimer) {
|
||||
clearTimeout(app.startupTimer)
|
||||
app.startupTimer = undefined
|
||||
app.proc = proc
|
||||
|
||||
// Check if process is alive using ps(1) - more reliable than Bun's API
|
||||
const isProcessAlive = async (pid: number): Promise<boolean> => {
|
||||
try {
|
||||
const ps = Bun.spawn(['ps', '-p', String(pid)], { stdout: 'pipe', stderr: 'pipe' })
|
||||
const code = await ps.exited
|
||||
return code === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
app.state = 'running'
|
||||
app.proc = proc
|
||||
app.started = Date.now()
|
||||
update()
|
||||
// Poll to verify app started - tries /ok for HTTP apps, falls back to survival check
|
||||
const pollStartup = async () => {
|
||||
const pollInterval = 500
|
||||
const survivalThreshold = 5000 // Consider non-HTTP apps running after 5s
|
||||
const startTime = Date.now()
|
||||
const pid = proc.pid
|
||||
|
||||
// Start health checks
|
||||
startHealthChecks(app, port)
|
||||
while (app.state === 'starting' && app.proc === proc) {
|
||||
// First check if process is still alive
|
||||
const alive = await isProcessAlive(pid)
|
||||
if (!alive) {
|
||||
info(app, 'Process died during startup')
|
||||
// proc.exited handler will clean up
|
||||
return
|
||||
}
|
||||
|
||||
// Try /ok endpoint for HTTP apps
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 2000)
|
||||
const response = await fetch(`http://localhost:${port}/ok`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (response.ok) {
|
||||
// HTTP app is running and healthy
|
||||
markAsRunning(app, port, true)
|
||||
return
|
||||
}
|
||||
|
||||
// App responded but /ok returned error - mark as error and kill
|
||||
info(app, `/ok returned ${response.status}`)
|
||||
app.error = `Health check failed: /ok returned ${response.status}`
|
||||
app.proc?.kill()
|
||||
return
|
||||
} catch {
|
||||
// Connection failed - app not ready yet or not an HTTP app
|
||||
}
|
||||
|
||||
// If process survived long enough, consider it running (non-HTTP app)
|
||||
if (Date.now() - startTime >= survivalThreshold) {
|
||||
info(app, 'No /ok endpoint, marking as running (process survived 5s)')
|
||||
markAsRunning(app, port, false)
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
}
|
||||
}
|
||||
|
||||
pollStartup()
|
||||
|
||||
const streamOutput = async (stream: ReadableStream<Uint8Array> | null, streamType: 'stdout' | 'stderr') => {
|
||||
if (!stream) return
|
||||
|
|
@ -639,8 +710,8 @@ async function runApp(dir: string, port: number) {
|
|||
releasePort(app.port)
|
||||
}
|
||||
|
||||
// Reset to stopped state (or invalid if no longer valid)
|
||||
app.state = isApp(dir) ? 'stopped' : 'invalid'
|
||||
// Reset to stopped state (or invalid if error or no longer valid)
|
||||
app.state = (isApp(dir) && !app.error) ? 'stopped' : 'invalid'
|
||||
app.proc = undefined
|
||||
app.port = undefined
|
||||
app.started = undefined
|
||||
|
|
@ -733,6 +804,38 @@ function startHealthChecks(app: App, port: number) {
|
|||
}, HEALTH_CHECK_INTERVAL)
|
||||
}
|
||||
|
||||
function startProcessHealthChecks(app: App) {
|
||||
// For non-HTTP apps, just verify process is still alive using ps(1)
|
||||
app.healthCheckTimer = setInterval(async () => {
|
||||
if (app.state !== 'running') {
|
||||
if (app.healthCheckTimer) {
|
||||
clearInterval(app.healthCheckTimer)
|
||||
app.healthCheckTimer = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const pid = app.proc?.pid
|
||||
if (!pid) {
|
||||
handleHealthCheckFailure(app)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ps = Bun.spawn(['ps', '-p', String(pid)], { stdout: 'pipe', stderr: 'pipe' })
|
||||
const code = await ps.exited
|
||||
if (code === 0) {
|
||||
// Process is alive
|
||||
app.consecutiveHealthFailures = 0
|
||||
} else {
|
||||
handleHealthCheckFailure(app)
|
||||
}
|
||||
} catch {
|
||||
handleHealthCheckFailure(app)
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL)
|
||||
}
|
||||
|
||||
function startShutdownTimeout(app: App) {
|
||||
app.shutdownTimer = setTimeout(() => {
|
||||
if (app.proc && (app.state === 'stopping' || app.state === 'running')) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user