1025 lines
30 KiB
TypeScript
1025 lines
30 KiB
TypeScript
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 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 APPS_DIR = process.env.APPS_DIR!
|
|
const TOES_DIR = process.env.TOES_DIR!
|
|
const TOES_URL = process.env.TOES_URL!
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
interface App {
|
|
name: string
|
|
state: string
|
|
port?: number
|
|
pid?: number
|
|
tool?: boolean
|
|
}
|
|
|
|
interface AppMetrics extends App {
|
|
cpu?: number
|
|
dataSize?: number
|
|
memory?: number
|
|
rss?: number
|
|
}
|
|
|
|
interface HistorySample {
|
|
timestamp: number
|
|
cpu: number
|
|
memory: number
|
|
rss: number
|
|
}
|
|
|
|
interface DataSample {
|
|
date: string
|
|
bytes: number
|
|
}
|
|
|
|
interface ProcessMetrics {
|
|
pid: number
|
|
cpu: number
|
|
memory: number
|
|
rss: number
|
|
}
|
|
|
|
// ============================================================================
|
|
// Process Metrics Collection
|
|
// ============================================================================
|
|
|
|
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>()
|
|
|
|
function getDataHistory(appName: string): DataSample[] {
|
|
return dataHistory.get(appName) ?? []
|
|
}
|
|
|
|
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 {
|
|
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)
|
|
}
|
|
|
|
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 sampleProcessMetrics()
|
|
|
|
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 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
|
|
}
|
|
}
|
|
|
|
// Start sampling on module load
|
|
sampleAndRecordHistory()
|
|
setInterval(sampleAndRecordHistory, SAMPLE_INTERVAL_MS)
|
|
|
|
// ============================================================================
|
|
// Data Size
|
|
// ============================================================================
|
|
|
|
function getDataSize(appName: string): number {
|
|
let total = 0
|
|
const appDir = join(APPS_DIR, appName)
|
|
if (existsSync(appDir)) total += dirSize(appDir)
|
|
const dataDir = join(TOES_DIR, appName)
|
|
if (existsSync(dataDir)) total += dirSize(dataDir)
|
|
return total
|
|
}
|
|
|
|
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
|
|
// ============================================================================
|
|
|
|
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 getAppMetrics(): Promise<AppMetrics[]> {
|
|
const apps = await fetchApps()
|
|
return apps.map(app => {
|
|
const metrics = app.pid ? getProcessMetrics(app.pid) : undefined
|
|
return {
|
|
...app,
|
|
cpu: metrics?.cpu,
|
|
dataSize: getDataSize(app.name),
|
|
memory: metrics?.memory,
|
|
rss: metrics?.rss,
|
|
}
|
|
})
|
|
}
|
|
|
|
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 metrics = app.pid ? getProcessMetrics(app.pid) : undefined
|
|
return {
|
|
...app,
|
|
cpu: metrics?.cpu,
|
|
dataSize: getDataSize(app.name),
|
|
memory: metrics?.memory,
|
|
rss: metrics?.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 Tab = define('Tab', {
|
|
base: 'a',
|
|
padding: '8px 16px',
|
|
fontSize: '13px',
|
|
fontFamily: theme('fonts-sans'),
|
|
color: theme('colors-textMuted'),
|
|
textDecoration: 'none',
|
|
borderBottom: '2px solid transparent',
|
|
cursor: 'pointer',
|
|
states: {
|
|
':hover': {
|
|
color: theme('colors-text'),
|
|
},
|
|
},
|
|
})
|
|
|
|
const TabActive = define('TabActive', {
|
|
base: 'a',
|
|
padding: '8px 16px',
|
|
fontSize: '13px',
|
|
fontFamily: theme('fonts-sans'),
|
|
color: theme('colors-text'),
|
|
textDecoration: 'none',
|
|
borderBottom: `2px solid ${theme('colors-primary')}`,
|
|
fontWeight: 'bold',
|
|
cursor: 'default',
|
|
})
|
|
|
|
const TabBar = define('TabBar', {
|
|
display: 'flex',
|
|
gap: '4px',
|
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
marginBottom: '15px',
|
|
})
|
|
|
|
const ChartsContainer = define('ChartsContainer', {
|
|
marginTop: '24px',
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(2, 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 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 {
|
|
if (value === undefined) return '-'
|
|
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':
|
|
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/metrics', async c => {
|
|
const metrics = await getAppMetrics()
|
|
return c.json(metrics)
|
|
})
|
|
|
|
app.get('/api/metrics/:name', async c => {
|
|
const name = c.req.param('name')
|
|
const metrics = await getAppMetricsByName(name)
|
|
if (!metrics) {
|
|
return c.json({ error: 'App not found' }, 404)
|
|
}
|
|
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 => {
|
|
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 tab = c.req.query('tab') === 'global' ? 'global' : 'app'
|
|
const appUrl = `/?app=${appName}`
|
|
const globalUrl = `/?app=${appName}&tab=global`
|
|
|
|
if (tab === 'global') {
|
|
const metrics = await getAppMetrics()
|
|
|
|
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 (metrics.length === 0) {
|
|
return c.html(
|
|
<Layout title="Metrics">
|
|
<TabBar>
|
|
<Tab href={appUrl}>App</Tab>
|
|
<TabActive href={globalUrl}>Global</TabActive>
|
|
</TabBar>
|
|
<EmptyState>No apps found</EmptyState>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
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="Metrics - Global">
|
|
<TabBar>
|
|
<Tab href={appUrl}>App</Tab>
|
|
<TabActive href={globalUrl}>Global</TabActive>
|
|
</TabBar>
|
|
<Table>
|
|
<thead>
|
|
<tr>
|
|
<Th>Name</Th>
|
|
<Th>State</Th>
|
|
<ThRight>PID</ThRight>
|
|
<ThRight>CPU</ThRight>
|
|
<ThRight>MEM</ThRight>
|
|
<ThRight>RSS</ThRight>
|
|
<ThRight>Data</ThRight>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{metrics.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>
|
|
<TdRight>{formatBytes(s.dataSize)}</TdRight>
|
|
</Tr>
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
<Summary>
|
|
{running.length} running · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} RSS · {formatBytes(totalData)} data
|
|
</Summary>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
const metrics = await getAppMetricsByName(appName)
|
|
|
|
if (!metrics) {
|
|
return c.html(
|
|
<Layout title="Metrics">
|
|
<EmptyState>App not found: {appName}</EmptyState>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
return c.html(
|
|
<Layout title="Metrics">
|
|
<TabBar>
|
|
<TabActive href={appUrl}>App</TabActive>
|
|
<Tab href={globalUrl}>Global</Tab>
|
|
</TabBar>
|
|
<Table>
|
|
<thead>
|
|
<tr>
|
|
<Th>State</Th>
|
|
<ThRight>PID</ThRight>
|
|
<ThRight>CPU</ThRight>
|
|
<ThRight>MEM</ThRight>
|
|
<ThRight>RSS</ThRight>
|
|
<ThRight>Data</ThRight>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<Tr>
|
|
<Td>
|
|
<StatusBadge style={`color: ${getStatusColor(metrics.state)}`}>
|
|
{metrics.state}
|
|
</StatusBadge>
|
|
</Td>
|
|
<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>
|
|
|
|
<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>
|
|
<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>
|
|
<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 dataColor = 'rgb(245, 158, 11)';
|
|
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, 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);
|
|
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 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));
|
|
if (res.ok) {
|
|
const history = await res.json();
|
|
updateCharts(history);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch history:', e);
|
|
}
|
|
}
|
|
|
|
// Initial fetch
|
|
fetchHistory();
|
|
fetchDataHistory();
|
|
|
|
// Update every 10 seconds
|
|
setInterval(fetchHistory, 10000);
|
|
setInterval(fetchDataHistory, 60000); // Data size changes slowly
|
|
})();
|
|
`}} />
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
// All apps view
|
|
const metrics = await getAppMetrics()
|
|
|
|
// Sort: running first, then by name
|
|
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 (metrics.length === 0) {
|
|
return c.html(
|
|
<Layout title="Metrics">
|
|
<EmptyState>No apps found</EmptyState>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
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="Metrics">
|
|
<Table>
|
|
<thead>
|
|
<tr>
|
|
<Th>Name</Th>
|
|
<Th>State</Th>
|
|
<ThRight>PID</ThRight>
|
|
<ThRight>CPU</ThRight>
|
|
<ThRight>MEM</ThRight>
|
|
<ThRight>RSS</ThRight>
|
|
<ThRight>Data</ThRight>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{metrics.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>
|
|
<TdRight>{formatBytes(s.dataSize)}</TdRight>
|
|
</Tr>
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
<Summary>
|
|
{running.length} running · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} RSS · {formatBytes(totalData)} data
|
|
</Summary>
|
|
</Layout>
|
|
)
|
|
})
|
|
|
|
export default app.defaults
|