dashboard

This commit is contained in:
Chris Wanstrath 2026-02-13 08:40:29 -08:00
parent 8b31fa3f19
commit 720c0e76fb
14 changed files with 375 additions and 315 deletions

View File

@ -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",

View File

@ -65,7 +65,7 @@ export function AppSelector({ render, onSelect, collapsed, switcherStyle, listSt
<>
<span style={{ fontSize: 14 }}>{app.icon}</span>
{app.name}
<StatusDot state={app.state} style={{ marginLeft: 'auto' }} />
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
</>
)}
</AppItem>

View File

@ -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<SystemMetrics>({
cpu: 0,
ram: { used: 0, total: 0, percent: 0 },
disk: { used: 0, total: 0, percent: 0 },
})
const [logs, setLogs] = useState<UnifiedLogLine[]>([])
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 (
<DashboardContainer>
<DashboardHeader>
<DashboardTitle>Toes</DashboardTitle>
<DashboardSubtitle>Your personal web appliance</DashboardSubtitle>
<DashboardTitle>🐾 Toes</DashboardTitle>
{/*<DashboardSubtitle>Your personal web appliance</DashboardSubtitle>*/}
</DashboardHeader>
<StatsGrid>
<StatCard>
<StatValue>{regularApps.length}</StatValue>
<StatLabel>Apps</StatLabel>
</StatCard>
<StatCard>
<StatValue>{toolApps.length}</StatValue>
<StatLabel>Tools</StatLabel>
</StatCard>
<StatCard>
<StatValue>{runningApps.length}</StatValue>
<StatLabel>Running</StatLabel>
</StatCard>
</StatsGrid>
<StatusDotsRow>
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
<StatusDotLink key={app.name} data-tooltip={app.name} onClick={(e: Event) => {
e.preventDefault()
setSelectedApp(app.name)
update()
}}>
<StatusDot state={app.state} data-app={app.name} />
</StatusDotLink>
))}
</StatusDotsRow>
<Vitals cpu={metrics.cpu} ram={metrics.ram} disk={metrics.disk} />
<Vitals />
<UnifiedLogs logs={logs} onClear={handleClearLogs} />
<UnifiedLogs />
</DashboardContainer>
)
}

View File

@ -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',

View File

@ -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<HTMLDivElement>(null)
function LogsBodyContent() {
return (
<>
{_logs.length === 0 ? (
<LogEntry>
<LogText style={{ color: 'var(--colors-textFaint)' }}>No activity yet</LogText>
</LogEntry>
) : (
_logs.map((log, i) => <LogLineEntry key={i} log={log} />)
)}
</>
)
}
// 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', <LogsBodyContent />)
// 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 (
<LogsSection>
<LogsHeader>
<LogsTitle>Activity</LogsTitle>
<LogsClearButton onClick={onClear}>Clear</LogsClearButton>
<LogsTitle>Logs</LogsTitle>
</LogsHeader>
<LogsBody ref={bodyRef}>
{logs.length === 0 ? (
<LogEntry>
<LogText style={{ color: 'var(--colors-textFaint)' }}>No activity yet</LogText>
</LogEntry>
) : (
logs.map((log, i) => <LogLine key={i} log={log} />)
)}
<LogsBody id="unified-logs-body">
<LogsBodyContent />
</LogsBody>
</LogsSection>
)

View File

@ -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<string, AppMetrics>
}
// 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<string, AppMetrics>) {
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(
<path
key={i}
d={`M ${x1} ${y1} A ${outerR} ${outerR} 0 0 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 0 0 ${x4} ${y4} Z`}
fill={color}
/>
)
}
// 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 (
<GaugeContainer>
<GaugeSvg viewBox="0 0 120 70">
{/* Track */}
<path
d={trackPath}
fill="none"
stroke="var(--colors-border)"
stroke-width="6"
stroke-linecap="round"
/>
{/* Value */}
{value > 0 && (
<path
d={valuePath}
fill="none"
stroke="var(--colors-textMuted)"
stroke-width="6"
stroke-linecap="round"
/>
)}
{/* Needle */}
<line
x1={centerX}
y1={centerY}
x2={valueX}
y2={valueY}
stroke="var(--colors-text)"
stroke-width="2"
stroke-linecap="round"
/>
<circle cx={centerX} cy={centerY} r="4" fill="var(--colors-text)" />
<GaugeSvg viewBox="10 10 100 55">
{segments}
<polygon points={`${nx},${ny} ${bx1},${by1} ${bx2},${by2}`} fill="var(--colors-text)" />
<circle cx={CX} cy={CY} r="4" fill="var(--colors-textMuted)" />
</GaugeSvg>
<GaugeValue>{value}%</GaugeValue>
</GaugeContainer>
)
}
function BarGauge({ value }: { value: number }) {
function VitalsContent() {
return (
<BarGaugeContainer>
<BarGaugeTrack>
<BarGaugeFill style={{ width: `${value}%` }} />
</BarGaugeTrack>
<BarGaugeLabel>{value}%</BarGaugeLabel>
</BarGaugeContainer>
)
}
function CircleGauge({ value }: { value: number }) {
const radius = 32
const circumference = 2 * Math.PI * radius
const offset = circumference - (value / 100) * circumference
return (
<CircleGaugeContainer>
<CircleGaugeSvg viewBox="0 0 80 80">
{/* Track */}
<circle
cx="40"
cy="40"
r={radius}
fill="none"
stroke="var(--colors-border)"
stroke-width="6"
/>
{/* Value */}
<circle
cx="40"
cy="40"
r={radius}
fill="none"
stroke="var(--colors-textMuted)"
stroke-width="6"
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={offset}
/>
</CircleGaugeSvg>
<CircleGaugeValue>{value}%</CircleGaugeValue>
</CircleGaugeContainer>
)
}
export function Vitals({ cpu, ram, disk }: VitalsProps) {
return (
<VitalsSection>
<>
<VitalCard>
<VitalLabel>CPU</VitalLabel>
<ArcGauge value={cpu} />
<Gauge value={_metrics.cpu} />
</VitalCard>
<VitalCard>
<VitalLabel>RAM</VitalLabel>
<BarGauge value={ram.percent} />
<Gauge value={_metrics.ram.percent} />
</VitalCard>
<VitalCard>
<VitalLabel>Disk</VitalLabel>
<CircleGauge value={disk.percent} />
<Gauge value={_metrics.disk.percent} />
</VitalCard>
</>
)
}
export function initVitals() {
if (_source) return
_source = new EventSource('/api/system/metrics/stream')
_source.onmessage = e => {
try {
_metrics = JSON.parse(e.data)
update('#vitals', <VitalsContent />)
updateTooltips(_metrics.apps)
} catch {}
}
}
export function Vitals() {
return (
<VitalsSection id="vitals">
<VitalsContent />
</VitalsSection>
)
}

View File

@ -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()
}

View File

@ -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

View File

@ -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,

View File

@ -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', {

View File

@ -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,

View File

@ -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<string, AppMetrics>
}
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<string, number> = {}
let _appDiskLastUpdate = 0
const DISK_CACHE_TTL = 30000
function getAppMetrics(): Record<string, AppMetrics> {
const apps = allApps()
const running = apps.filter(a => a.proc?.pid)
const result: Record<string, AppMetrics> = {}
// CPU + MEM via ps (works on both macOS and Linux)
const pidToName = new Map<number, string>()
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)
}

View File

@ -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)

View File

@ -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