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