diff --git a/package.json b/package.json index 0f0634b..fc58077 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes", "cli:uninstall": "sudo rm /usr/local/bin", "deploy": "./scripts/deploy.sh", - "dev": "rm pub/client/index.js && bun run --hot src/server/index.tsx", + "debug": "DEBUG=1 bun run dev", + "dev": "rm -f pub/client/index.js && bun run --hot src/server/index.tsx", "remote:deploy": "./scripts/deploy.sh", "remote:install": "./scripts/remote-install.sh", "remote:logs": "./scripts/remote-logs.sh", diff --git a/src/client/components/AppSelector.tsx b/src/client/components/AppSelector.tsx index 19c8a82..dc856e3 100644 --- a/src/client/components/AppSelector.tsx +++ b/src/client/components/AppSelector.tsx @@ -65,7 +65,7 @@ export function AppSelector({ render, onSelect, collapsed, switcherStyle, listSt <> {app.icon} {app.name} - + )} diff --git a/src/client/components/DashboardLanding.tsx b/src/client/components/DashboardLanding.tsx index f81d539..723bfb0 100644 --- a/src/client/components/DashboardLanding.tsx +++ b/src/client/components/DashboardLanding.tsx @@ -1,91 +1,46 @@ -import { useEffect, useState } from 'hono/jsx' -import { apps } from '../state' +import { useEffect } from 'hono/jsx' +import { apps, setSelectedApp } from '../state' import { DashboardContainer, DashboardHeader, DashboardSubtitle, DashboardTitle, - StatCard, - StatLabel, - StatValue, - StatsGrid, + StatusDot, + StatusDotLink, + StatusDotsRow, } from '../styles' -import { UnifiedLogs, type UnifiedLogLine } from './UnifiedLogs' -import { Vitals } from './Vitals' - -interface SystemMetrics { - cpu: number - ram: { used: number, total: number, percent: number } - disk: { used: number, total: number, percent: number } -} +import { update } from '../update' +import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs' +import { Vitals, initVitals } from './Vitals' export function DashboardLanding() { - const [metrics, setMetrics] = useState({ - cpu: 0, - ram: { used: 0, total: 0, percent: 0 }, - disk: { used: 0, total: 0, percent: 0 }, - }) - const [logs, setLogs] = useState([]) - - const regularApps = apps.filter(app => !app.tool) - const toolApps = apps.filter(app => app.tool) - const runningApps = apps.filter(app => app.state === 'running') - - // Subscribe to system metrics SSE useEffect(() => { - const metricsSource = new EventSource('/api/system/metrics/stream') - metricsSource.onmessage = e => { - try { - setMetrics(JSON.parse(e.data)) - } catch {} - } - return () => metricsSource.close() + initUnifiedLogs() + initVitals() }, []) - // Subscribe to unified logs SSE - useEffect(() => { - const logsSource = new EventSource('/api/system/logs/stream') - logsSource.onmessage = e => { - try { - const line = JSON.parse(e.data) as UnifiedLogLine - setLogs((prev: UnifiedLogLine[]) => [...prev.slice(-199), line]) - } catch {} - } - return () => logsSource.close() - }, []) - - const handleClearLogs = async () => { - try { - await fetch('/api/system/logs/clear', { method: 'POST' }) - setLogs([]) - } catch {} - } - return ( - Toes - Your personal web appliance + 🐾 Toes + {/*Your personal web appliance*/} - - - {regularApps.length} - Apps - - - {toolApps.length} - Tools - - - {runningApps.length} - Running - - + + {[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => ( + { + e.preventDefault() + setSelectedApp(app.name) + update() + }}> + + + ))} + - + - + ) } diff --git a/src/client/components/LogsSection.tsx b/src/client/components/LogsSection.tsx index 6a139f5..ab18fb5 100644 --- a/src/client/components/LogsSection.tsx +++ b/src/client/components/LogsSection.tsx @@ -1,7 +1,7 @@ import { define } from '@because/forge' import type { App, LogLine as LogLineType } from '../../shared/types' import { getLogDates, getLogsForDate } from '../api' -import { LogLine, LogsContainer, LogTime, Section, SectionTitle } from '../styles' +import { LogLine, LogsContainer, LogsHeader, LogTime, Section, SectionTitle } from '../styles' import { theme } from '../themes' import { update } from '../update' @@ -32,14 +32,6 @@ const getState = (appName: string): LogsState => { return logsState.get(appName)! } -const LogsHeader = define('LogsHeader', { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: 12, - marginBottom: 12, -}) - const LogsControls = define('LogsControls', { display: 'flex', alignItems: 'center', diff --git a/src/client/components/UnifiedLogs.tsx b/src/client/components/UnifiedLogs.tsx index 78744e4..f250d13 100644 --- a/src/client/components/UnifiedLogs.tsx +++ b/src/client/components/UnifiedLogs.tsx @@ -1,15 +1,14 @@ -import { useEffect, useRef } from 'hono/jsx' import { LogApp, LogEntry, LogsBody, - LogsClearButton, LogsHeader, LogsSection, LogsTitle, LogText, LogTimestamp, } from '../styles' +import { update } from '../update' export interface UnifiedLogLine { time: number @@ -17,17 +16,25 @@ export interface UnifiedLogLine { text: string } -interface UnifiedLogsProps { - logs: UnifiedLogLine[] - onClear: () => void -} +const MAX_LOGS = 200 -function formatTime(timestamp: number): string { +let _logs: UnifiedLogLine[] = [] +let _source: EventSource | undefined + +const formatTime = (timestamp: number): string => { const d = new Date(timestamp) const pad = (n: number) => String(n).padStart(2, '0') return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` } +const getStatusColor = (status?: number): string | undefined => { + if (!status) return undefined + if (status >= 200 && status < 300) return '#22c55e' + if (status >= 400 && status < 500) return '#f59e0b' + if (status >= 500) return '#ef4444' + return undefined +} + function parseLogText(text: string): { method?: string, path?: string, status?: number, rest: string } { // Match patterns like "GET /api/time 200" or "200 GET http://... (0ms)" const httpMatch = text.match(/^(\d{3})\s+(GET|POST|PUT|DELETE|PATCH)\s+\S+/) @@ -44,15 +51,7 @@ function parseLogText(text: string): { method?: string, path?: string, status?: return { rest: text } } -function getStatusColor(status?: number): string | undefined { - if (!status) return undefined - if (status >= 200 && status < 300) return '#22c55e' - if (status >= 400 && status < 500) return '#f59e0b' - if (status >= 500) return '#ef4444' - return undefined -} - -function LogLine({ log }: { log: UnifiedLogLine }) { +function LogLineEntry({ log }: { log: UnifiedLogLine }) { const parsed = parseLogText(log.text) const statusColor = getStatusColor(parsed.status) @@ -67,30 +66,49 @@ function LogLine({ log }: { log: UnifiedLogLine }) { ) } -export function UnifiedLogs({ logs, onClear }: UnifiedLogsProps) { - const bodyRef = useRef(null) +function LogsBodyContent() { + return ( + <> + {_logs.length === 0 ? ( + + No activity yet + + ) : ( + _logs.map((log, i) => ) + )} + + ) +} - // Auto-scroll to bottom when new logs arrive - useEffect(() => { - if (bodyRef.current) { - bodyRef.current.scrollTop = bodyRef.current.scrollHeight - } - }, [logs.length]) +function renderLogs() { + update('#unified-logs-body', ) + // Auto-scroll after render + requestAnimationFrame(() => { + const el = document.getElementById('unified-logs-body') + if (el) el.scrollTop = el.scrollHeight + }) +} +export function initUnifiedLogs() { + if (_source) return + _source = new EventSource('/api/system/logs/stream') + _source.onmessage = e => { + try { + const line = JSON.parse(e.data) as UnifiedLogLine + _logs = [..._logs.slice(-(MAX_LOGS - 1)), line] + renderLogs() + } catch {} + } +} + +export function UnifiedLogs() { return ( - Activity - Clear + Logs - - {logs.length === 0 ? ( - - No activity yet - - ) : ( - logs.map((log, i) => ) - )} + + ) diff --git a/src/client/components/Vitals.tsx b/src/client/components/Vitals.tsx index 8b275c7..312c05c 100644 --- a/src/client/components/Vitals.tsx +++ b/src/client/components/Vitals.tsx @@ -1,11 +1,4 @@ import { - BarGaugeContainer, - BarGaugeFill, - BarGaugeLabel, - BarGaugeTrack, - CircleGaugeContainer, - CircleGaugeSvg, - CircleGaugeValue, GaugeContainer, GaugeSvg, GaugeValue, @@ -13,139 +6,160 @@ import { VitalLabel, VitalsSection, } from '../styles' +import { update } from '../update' -interface VitalsProps { +interface AppMetrics { cpu: number - ram: { percent: number } - disk: { percent: number } + mem: number + disk: number } -function ArcGauge({ value }: { value: number }) { - // Arc from -135 to 135 degrees (270 degree sweep) - const radius = 50 - const centerX = 60 - const centerY = 60 - const startAngle = -135 - const endAngle = 135 - const sweepAngle = endAngle - startAngle +interface SystemMetrics { + cpu: number + ram: { used: number, total: number, percent: number } + disk: { used: number, total: number, percent: number } + apps: Record +} - // Calculate the value angle - const valueAngle = startAngle + (value / 100) * sweepAngle +const SEGMENTS = 19 +const START_ANGLE = -225 +const END_ANGLE = 45 +const SWEEP = END_ANGLE - START_ANGLE +const CX = 60 +const CY = 60 +const RADIUS = 44 +const SEGMENT_GAP = 3 +const SEGMENT_WIDTH = 8 +const NEEDLE_LENGTH = 38 - // Convert angles to radians and calculate points - const toRad = (deg: number) => (deg * Math.PI) / 180 - const startX = centerX + radius * Math.cos(toRad(startAngle)) - const startY = centerY + radius * Math.sin(toRad(startAngle)) - const endX = centerX + radius * Math.cos(toRad(endAngle)) - const endY = centerY + radius * Math.sin(toRad(endAngle)) - const valueX = centerX + radius * Math.cos(toRad(valueAngle)) - const valueY = centerY + radius * Math.sin(toRad(valueAngle)) +let _metrics: SystemMetrics = { + cpu: 0, + ram: { used: 0, total: 0, percent: 0 }, + disk: { used: 0, total: 0, percent: 0 }, + apps: {}, +} +let _source: EventSource | undefined - // Create arc paths - const trackPath = `M ${startX} ${startY} A ${radius} ${radius} 0 1 1 ${endX} ${endY}` - const valuePath = value > 0 - ? `M ${startX} ${startY} A ${radius} ${radius} 0 ${value > 50 ? 1 : 0} 1 ${valueX} ${valueY}` - : '' +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K` + if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}M` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G` +} + +function updateTooltips(apps: Record) { + for (const [name, m] of Object.entries(apps)) { + document.querySelectorAll(`[data-app="${name}"]`).forEach(dot => { + const parent = dot.parentElement + if (parent?.hasAttribute('data-tooltip')) { + parent.setAttribute('data-tooltip', + `${name}\n${'─'.repeat(name.length)}\nCPU ${m.cpu}%\nMEM ${formatBytes(m.mem)}\nDISK ${formatBytes(m.disk)}`) + } + }) + } +} + +const toRad = (deg: number) => (deg * Math.PI) / 180 + +const segmentColor = (i: number, total: number): string => { + const t = i / (total - 1) + if (t < 0.4) return '#4caf50' + if (t < 0.6) return '#8bc34a' + if (t < 0.75) return '#ffc107' + if (t < 0.9) return '#ff9800' + return '#f44336' +} + +function Gauge({ value }: { value: number }) { + const segmentSweep = SWEEP / SEGMENTS + const activeSegments = Math.round((value / 100) * SEGMENTS) + + const segments = [] + for (let i = 0; i < SEGMENTS; i++) { + const startDeg = START_ANGLE + i * segmentSweep + SEGMENT_GAP / 2 + const endDeg = START_ANGLE + (i + 1) * segmentSweep - SEGMENT_GAP / 2 + const innerR = RADIUS - SEGMENT_WIDTH / 2 + const outerR = RADIUS + SEGMENT_WIDTH / 2 + + const x1 = CX + outerR * Math.cos(toRad(startDeg)) + const y1 = CY + outerR * Math.sin(toRad(startDeg)) + const x2 = CX + outerR * Math.cos(toRad(endDeg)) + const y2 = CY + outerR * Math.sin(toRad(endDeg)) + const x3 = CX + innerR * Math.cos(toRad(endDeg)) + const y3 = CY + innerR * Math.sin(toRad(endDeg)) + const x4 = CX + innerR * Math.cos(toRad(startDeg)) + const y4 = CY + innerR * Math.sin(toRad(startDeg)) + + const color = i < activeSegments ? segmentColor(i, SEGMENTS) : 'var(--colors-border)' + + segments.push( + + ) + } + + // Needle + const needleAngle = START_ANGLE + (value / 100) * SWEEP + const nx = CX + NEEDLE_LENGTH * Math.cos(toRad(needleAngle)) + const ny = CY + NEEDLE_LENGTH * Math.sin(toRad(needleAngle)) + // Needle base width + const perpAngle = needleAngle + 90 + const bw = 3 + const bx1 = CX + bw * Math.cos(toRad(perpAngle)) + const by1 = CY + bw * Math.sin(toRad(perpAngle)) + const bx2 = CX - bw * Math.cos(toRad(perpAngle)) + const by2 = CY - bw * Math.sin(toRad(perpAngle)) return ( - - {/* Track */} - - {/* Value */} - {value > 0 && ( - - )} - {/* Needle */} - - + + {segments} + + {value}% ) } -function BarGauge({ value }: { value: number }) { +function VitalsContent() { return ( - - - - - {value}% - - ) -} - -function CircleGauge({ value }: { value: number }) { - const radius = 32 - const circumference = 2 * Math.PI * radius - const offset = circumference - (value / 100) * circumference - - return ( - - - {/* Track */} - - {/* Value */} - - - {value}% - - ) -} - -export function Vitals({ cpu, ram, disk }: VitalsProps) { - return ( - + <> CPU - + RAM - + Disk - + + + ) +} + +export function initVitals() { + if (_source) return + _source = new EventSource('/api/system/metrics/stream') + _source.onmessage = e => { + try { + _metrics = JSON.parse(e.data) + update('#vitals', ) + updateTooltips(_metrics.apps) + } catch {} + } +} + +export function Vitals() { + return ( + + ) } diff --git a/src/client/index.tsx b/src/client/index.tsx index 0495d55..c6165d0 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -44,10 +44,26 @@ narrowQuery.addEventListener('change', e => { // SSE connection const events = new EventSource('/api/apps/stream') events.onmessage = e => { + const prev = apps setApps(JSON.parse(e.data)) - // If selected app no longer exists, clear selection to show dashboard - if (selectedApp && !apps.some(a => a.name === selectedApp)) { - setSelectedApp(null) + + // Full re-render if app list changed structurally or selected app disappeared + const added = apps.some(a => !prev.find(p => p.name === a.name)) + const removed = prev.some(p => !apps.find(a => a.name === p.name)) + if (added || removed || (selectedApp && !apps.some(a => a.name === selectedApp))) { + if (selectedApp && !apps.some(a => a.name === selectedApp)) { + setSelectedApp(null) + } + render() + return + } + + // Targeted DOM updates for state-only changes + const states = ['running', 'stopped', 'starting', 'stopping', 'invalid'] + for (const app of apps) { + document.querySelectorAll(`[data-app="${app.name}"]`).forEach(dot => { + for (const s of states) dot.classList.remove(`state-${s}`) + dot.classList.add(`state-${app.state}`) + }) } - render() } diff --git a/src/client/styles/dashboard.ts b/src/client/styles/dashboard.ts index 7aeb04e..775144d 100644 --- a/src/client/styles/dashboard.ts +++ b/src/client/styles/dashboard.ts @@ -29,84 +29,26 @@ export const VitalLabel = define('VitalLabel', { letterSpacing: '0.05em', }) -// Arc Gauge (for CPU) export const GaugeContainer = define('GaugeContainer', { - position: 'relative', - width: 120, - height: 70, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', }) export const GaugeSvg = define('GaugeSvg', { base: 'svg', - width: '100%', - height: '100%', + width: 120, + height: 70, overflow: 'visible', }) export const GaugeValue = define('GaugeValue', { - position: 'absolute', - bottom: 0, - left: '50%', - transform: 'translateX(-50%)', - fontSize: 24, - fontWeight: 'bold', - fontFamily: theme('fonts-mono'), - color: theme('colors-text'), -}) - -// Bar Gauge (for RAM) -export const BarGaugeContainer = define('BarGaugeContainer', { - width: '100%', - maxWidth: 120, -}) - -export const BarGaugeTrack = define('BarGaugeTrack', { - width: '100%', - height: 12, - background: theme('colors-border'), - borderRadius: 6, - overflow: 'hidden', -}) - -export const BarGaugeFill = define('BarGaugeFill', { - height: '100%', - background: theme('colors-textMuted'), - borderRadius: 6, - transition: 'width 0.3s ease', -}) - -export const BarGaugeLabel = define('BarGaugeLabel', { - marginTop: 8, - fontSize: 24, - fontWeight: 'bold', - fontFamily: theme('fonts-mono'), - color: theme('colors-text'), - textAlign: 'center', -}) - -// Circular Gauge (for Disk) -export const CircleGaugeContainer = define('CircleGaugeContainer', { - position: 'relative', - width: 80, - height: 80, -}) - -export const CircleGaugeSvg = define('CircleGaugeSvg', { - base: 'svg', - width: '100%', - height: '100%', - transform: 'rotate(-90deg)', -}) - -export const CircleGaugeValue = define('CircleGaugeValue', { - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - fontSize: 18, + fontSize: 20, fontWeight: 'bold', fontFamily: theme('fonts-mono'), color: theme('colors-text'), + marginTop: 20, + marginLeft: 5, }) // Unified Logs Section diff --git a/src/client/styles/index.ts b/src/client/styles/index.ts index 965069e..153267a 100644 --- a/src/client/styles/index.ts +++ b/src/client/styles/index.ts @@ -1,12 +1,5 @@ export { ActionBar, Button, NewAppButton } from './buttons' export { - BarGaugeContainer, - BarGaugeFill, - BarGaugeLabel, - BarGaugeTrack, - CircleGaugeContainer, - CircleGaugeSvg, - CircleGaugeValue, GaugeContainer, GaugeSvg, GaugeValue, @@ -65,6 +58,8 @@ export { SectionTitle, stateLabels, StatusDot, + StatusDotLink, + StatusDotsRow, Tab, TabBar, TabContent, diff --git a/src/client/styles/layout.ts b/src/client/styles/layout.ts index c8d93f2..9840f4b 100644 --- a/src/client/styles/layout.ts +++ b/src/client/styles/layout.ts @@ -203,6 +203,7 @@ export const DashboardContainer = define('DashboardContainer', { alignItems: 'center', justifyContent: 'center', padding: 40, + paddingTop: 0, gap: 40, }) @@ -214,7 +215,6 @@ export const DashboardTitle = define('DashboardTitle', { fontSize: 48, fontWeight: 'bold', margin: 0, - marginBottom: 8, }) export const DashboardSubtitle = define('DashboardSubtitle', { diff --git a/src/client/styles/misc.ts b/src/client/styles/misc.ts index 3d16a22..0088ef2 100644 --- a/src/client/styles/misc.ts +++ b/src/client/styles/misc.ts @@ -2,6 +2,44 @@ import { define } from '@because/forge' import { theme } from '../themes' import type { AppState } from '../../shared/types' +export const StatusDotLink = define('StatusDotLink', { + base: 'a', + position: 'relative', + cursor: 'pointer', + textDecoration: 'none', + selectors: { + '&::after': { + content: 'attr(data-tooltip)', + position: 'absolute', + bottom: '100%', + left: '50%', + transform: 'translateX(-50%)', + marginBottom: 6, + padding: '2px 6px', + fontSize: 10, + fontFamily: theme('fonts-mono'), + color: theme('colors-text'), + background: theme('colors-bgElement'), + border: `1px solid ${theme('colors-border')}`, + borderRadius: 4, + whiteSpace: 'pre', + opacity: 0, + pointerEvents: 'none', + transition: 'opacity 0.15s', + }, + '&:hover::after': { + opacity: 1, + }, + }, +}) + +export const StatusDotsRow = define('StatusDotsRow', { + display: 'flex', + gap: 8, + alignItems: 'center', + justifyContent: 'center', +}) + export const StatusDot = define('StatusDot', { width: 8, height: 8, diff --git a/src/server/api/system.ts b/src/server/api/system.ts index 82c0a0b..fe7120f 100644 --- a/src/server/api/system.ts +++ b/src/server/api/system.ts @@ -1,12 +1,20 @@ -import { allApps, onChange } from '$apps' +import { allApps, APPS_DIR, onChange } from '$apps' import { Hype } from '@because/hype' -import { cpus, freemem, totalmem } from 'os' -import { statfsSync } from 'fs' +import { cpus, platform, totalmem } from 'os' +import { join } from 'path' +import { readFileSync, statfsSync } from 'fs' + +export interface AppMetrics { + cpu: number + mem: number + disk: number +} export interface SystemMetrics { cpu: number // 0-100 ram: { used: number, total: number, percent: number } disk: { used: number, total: number, percent: number } + apps: Record } export interface UnifiedLogLine { @@ -54,12 +62,36 @@ function getCpuUsage(): number { function getMemoryUsage(): { used: number, total: number, percent: number } { const total = totalmem() - const free = freemem() - const used = total - free + const apps = allApps().filter(a => a.proc?.pid) + let used = 0 + + if (platform() === 'linux') { + for (const app of apps) { + try { + const status = readFileSync(`/proc/${app.proc!.pid}/status`, 'utf-8') + const match = status.match(/VmRSS:\s+(\d+)/) + if (match) used += parseInt(match[1]!, 10) * 1024 + } catch {} + } + } else { + // macOS: batch ps call for all pids + const pids = apps.map(a => a.proc!.pid).join(',') + if (pids) { + try { + const result = Bun.spawnSync(['ps', '-o', 'rss=', '-p', pids]) + const output = result.stdout.toString() + for (const line of output.split('\n')) { + const kb = parseInt(line.trim(), 10) + if (kb) used += kb * 1024 + } + } catch {} + } + } + return { used, total, - percent: Math.round((used / total) * 100), + percent: used > 0 ? Math.max(1, Math.round((used / total) * 100)) : 0, } } @@ -79,12 +111,68 @@ function getDiskUsage(): { used: number, total: number, percent: number } { } } +// Per-app disk cache (updated every 30s) +let _appDiskCache: Record = {} +let _appDiskLastUpdate = 0 +const DISK_CACHE_TTL = 30000 + +function getAppMetrics(): Record { + const apps = allApps() + const running = apps.filter(a => a.proc?.pid) + const result: Record = {} + + // CPU + MEM via ps (works on both macOS and Linux) + const pidToName = new Map() + for (const app of running) { + pidToName.set(app.proc!.pid, app.name) + } + + if (pidToName.size > 0) { + try { + const pids = [...pidToName.keys()].join(',') + const ps = Bun.spawnSync(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids]) + for (const line of ps.stdout.toString().split('\n')) { + const parts = line.trim().split(/\s+/) + if (parts.length < 3) continue + const pid = parseInt(parts[0]!, 10) + const cpu = parseFloat(parts[1]!) || 0 + const mem = (parseInt(parts[2]!, 10) || 0) * 1024 + const name = pidToName.get(pid) + if (name) result[name] = { cpu: Math.round(cpu), mem, disk: 0 } + } + } catch {} + } + + // Disk usage per app (cached) + const now = Date.now() + if (now - _appDiskLastUpdate > DISK_CACHE_TTL) { + _appDiskLastUpdate = now + _appDiskCache = {} + for (const app of apps) { + try { + const du = Bun.spawnSync(['du', '-sk', join(APPS_DIR, app.name)]) + const kb = parseInt(du.stdout.toString().trim().split('\t')[0]!, 10) + if (kb) _appDiskCache[app.name] = kb * 1024 + } catch {} + } + } + + // Merge disk into results, fill in stopped apps + for (const app of apps) { + if (!result[app.name]) result[app.name] = { cpu: 0, mem: 0, disk: 0 } + result[app.name]!.disk = _appDiskCache[app.name] ?? 0 + } + + return result +} + // Get current system metrics router.get('/metrics', c => { const metrics: SystemMetrics = { cpu: getCpuUsage(), ram: getMemoryUsage(), disk: getDiskUsage(), + apps: getAppMetrics(), } return c.json(metrics) }) @@ -96,6 +184,7 @@ router.sse('/metrics/stream', (send) => { cpu: getCpuUsage(), ram: getMemoryUsage(), disk: getDiskUsage(), + apps: getAppMetrics(), } send(metrics) } diff --git a/src/server/index.tsx b/src/server/index.tsx index ef83514..53bb048 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -4,7 +4,7 @@ import syncRouter from './api/sync' import systemRouter from './api/system' import { Hype } from '@because/hype' -const app = new Hype({ layout: false, logging: false }) +const app = new Hype({ layout: false, logging: !!process.env.DEBUG }) app.route('/api/apps', appsRouter) app.route('/api/sync', syncRouter) diff --git a/src/server/tui.ts b/src/server/tui.ts index 76c9f9f..9f02fdd 100644 --- a/src/server/tui.ts +++ b/src/server/tui.ts @@ -4,7 +4,7 @@ import { TOES_URL } from '$apps' const RENDER_DEBOUNCE = 50 let _apps: App[] = [] -let _enabled = process.stdout.isTTY ?? false +let _enabled = (process.stdout.isTTY ?? false) && !process.env.DEBUG let _lastRender = 0 let _renderTimer: Timer | undefined let _showEmoji = false