From 50e5c97bebbe9ba4229e6742fb75c1811d88d3a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 20:58:42 +0000 Subject: [PATCH] Add system vitals gauges and unified log stream to dashboard - Add /api/system endpoints for CPU, RAM, and disk metrics (SSE stream) - Add /api/system/logs for unified log stream from all apps (SSE stream) - Create Vitals component with three gauges: arc (CPU), bar (RAM), circular (Disk) - Create UnifiedLogs component with real-time scrolling logs and status highlighting - Update DashboardLanding with stats, vitals, and activity sections Design follows Dieter Rams / Teenage Engineering aesthetic with neutral palette. https://claude.ai/code/session_013L9HKHxMEoub76B1zuKive --- src/client/components/DashboardLanding.tsx | 53 +++++- src/client/components/UnifiedLogs.tsx | 97 ++++++++++ src/client/components/Vitals.tsx | 151 ++++++++++++++++ src/client/styles/dashboard.ts | 200 +++++++++++++++++++++ src/client/styles/index.ts | 25 +++ src/server/api/system.ts | 175 ++++++++++++++++++ src/server/index.tsx | 2 + 7 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 src/client/components/UnifiedLogs.tsx create mode 100644 src/client/components/Vitals.tsx create mode 100644 src/client/styles/dashboard.ts create mode 100644 src/server/api/system.ts diff --git a/src/client/components/DashboardLanding.tsx b/src/client/components/DashboardLanding.tsx index 183d3d2..f81d539 100644 --- a/src/client/components/DashboardLanding.tsx +++ b/src/client/components/DashboardLanding.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'hono/jsx' import { apps } from '../state' import { DashboardContainer, @@ -9,18 +10,64 @@ import { StatValue, StatsGrid, } 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 } +} 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() + }, []) + + // 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 + Toes Your personal web appliance + {regularApps.length} @@ -35,6 +82,10 @@ export function DashboardLanding() { Running + + + + ) } diff --git a/src/client/components/UnifiedLogs.tsx b/src/client/components/UnifiedLogs.tsx new file mode 100644 index 0000000..78744e4 --- /dev/null +++ b/src/client/components/UnifiedLogs.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef } from 'hono/jsx' +import { + LogApp, + LogEntry, + LogsBody, + LogsClearButton, + LogsHeader, + LogsSection, + LogsTitle, + LogText, + LogTimestamp, +} from '../styles' + +export interface UnifiedLogLine { + time: number + app: string + text: string +} + +interface UnifiedLogsProps { + logs: UnifiedLogLine[] + onClear: () => void +} + +function 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())}` +} + +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+/) + || text.match(/^(GET|POST|PUT|DELETE|PATCH)\s+(\S+)\s+(\d{3})/) + + if (httpMatch) { + if (httpMatch[1]?.match(/^\d{3}$/)) { + return { method: httpMatch[2], status: parseInt(httpMatch[1], 10), rest: text } + } else { + return { method: httpMatch[1], path: httpMatch[2], status: parseInt(httpMatch[3]!, 10), rest: text } + } + } + + 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 }) { + const parsed = parseLogText(log.text) + const statusColor = getStatusColor(parsed.status) + + return ( + + {formatTime(log.time)} + {log.app} + + {log.text} + + + ) +} + +export function UnifiedLogs({ logs, onClear }: UnifiedLogsProps) { + const bodyRef = useRef(null) + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (bodyRef.current) { + bodyRef.current.scrollTop = bodyRef.current.scrollHeight + } + }, [logs.length]) + + return ( + + + Activity + Clear + + + {logs.length === 0 ? ( + + No activity yet + + ) : ( + logs.map((log, i) => ) + )} + + + ) +} diff --git a/src/client/components/Vitals.tsx b/src/client/components/Vitals.tsx new file mode 100644 index 0000000..8b275c7 --- /dev/null +++ b/src/client/components/Vitals.tsx @@ -0,0 +1,151 @@ +import { + BarGaugeContainer, + BarGaugeFill, + BarGaugeLabel, + BarGaugeTrack, + CircleGaugeContainer, + CircleGaugeSvg, + CircleGaugeValue, + GaugeContainer, + GaugeSvg, + GaugeValue, + VitalCard, + VitalLabel, + VitalsSection, +} from '../styles' + +interface VitalsProps { + cpu: number + ram: { percent: number } + disk: { percent: 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 + + // Calculate the value angle + const valueAngle = startAngle + (value / 100) * sweepAngle + + // 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)) + + // 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}` + : '' + + return ( + + + {/* Track */} + + {/* Value */} + {value > 0 && ( + + )} + {/* Needle */} + + + + {value}% + + ) +} + +function BarGauge({ value }: { value: number }) { + 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 + + + + ) +} diff --git a/src/client/styles/dashboard.ts b/src/client/styles/dashboard.ts new file mode 100644 index 0000000..7aeb04e --- /dev/null +++ b/src/client/styles/dashboard.ts @@ -0,0 +1,200 @@ +import { define } from '@because/forge' +import { theme } from '../themes' + +// Vitals Section +export const VitalsSection = define('VitalsSection', { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: 24, + width: '100%', + maxWidth: 800, +}) + +export const VitalCard = define('VitalCard', { + background: theme('colors-bgElement'), + border: `1px solid ${theme('colors-border')}`, + borderRadius: theme('radius-md'), + padding: 24, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 16, +}) + +export const VitalLabel = define('VitalLabel', { + fontSize: 12, + fontWeight: 600, + color: theme('colors-textFaint'), + textTransform: 'uppercase', + letterSpacing: '0.05em', +}) + +// Arc Gauge (for CPU) +export const GaugeContainer = define('GaugeContainer', { + position: 'relative', + width: 120, + height: 70, +}) + +export const GaugeSvg = define('GaugeSvg', { + base: 'svg', + width: '100%', + height: '100%', + 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, + fontWeight: 'bold', + fontFamily: theme('fonts-mono'), + color: theme('colors-text'), +}) + +// Unified Logs Section +export const LogsSection = define('LogsSection', { + width: '100%', + maxWidth: 800, + background: theme('colors-bgElement'), + border: `1px solid ${theme('colors-border')}`, + borderRadius: theme('radius-md'), + overflow: 'hidden', +}) + +export const LogsHeader = define('LogsHeader', { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '12px 16px', + borderBottom: `1px solid ${theme('colors-border')}`, +}) + +export const LogsTitle = define('LogsTitle', { + fontSize: 12, + fontWeight: 600, + color: theme('colors-textFaint'), + textTransform: 'uppercase', + letterSpacing: '0.05em', +}) + +export const LogsClearButton = define('LogsClearButton', { + base: 'button', + background: theme('colors-border'), + border: 'none', + borderRadius: 4, + padding: '4px 10px', + fontSize: 11, + fontWeight: 600, + color: theme('colors-textMuted'), + cursor: 'pointer', + textTransform: 'uppercase', + letterSpacing: '0.03em', + selectors: { + '&:hover': { + background: theme('colors-bgHover'), + color: theme('colors-text'), + }, + }, +}) + +export const LogsBody = define('LogsBody', { + height: 200, + overflow: 'auto', + fontFamily: theme('fonts-mono'), + fontSize: 12, + lineHeight: 1.5, +}) + +export const LogEntry = define('LogEntry', { + display: 'flex', + gap: 8, + padding: '2px 16px', + selectors: { + '&:hover': { + background: theme('colors-bgHover'), + }, + }, +}) + +export const LogTimestamp = define('LogTimestamp', { + color: theme('colors-textFaint'), + flexShrink: 0, +}) + +export const LogApp = define('LogApp', { + color: theme('colors-textMuted'), + flexShrink: 0, + minWidth: 80, +}) + +export const LogText = define('LogText', { + color: theme('colors-text'), + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', +}) + +export const LogStatus = define('LogStatus', { + variants: { + success: { color: '#22c55e' }, + error: { color: '#ef4444' }, + warning: { color: '#f59e0b' }, + }, +}) diff --git a/src/client/styles/index.ts b/src/client/styles/index.ts index 6be7e61..965069e 100644 --- a/src/client/styles/index.ts +++ b/src/client/styles/index.ts @@ -1,4 +1,29 @@ export { ActionBar, Button, NewAppButton } from './buttons' +export { + BarGaugeContainer, + BarGaugeFill, + BarGaugeLabel, + BarGaugeTrack, + CircleGaugeContainer, + CircleGaugeSvg, + CircleGaugeValue, + GaugeContainer, + GaugeSvg, + GaugeValue, + LogApp, + LogEntry, + LogsBody, + LogsClearButton, + LogsHeader, + LogsSection, + LogsTitle, + LogStatus, + LogText, + LogTimestamp, + VitalCard, + VitalLabel, + VitalsSection, +} from './dashboard' export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from './forms' export { AppItem, diff --git a/src/server/api/system.ts b/src/server/api/system.ts new file mode 100644 index 0000000..82c0a0b --- /dev/null +++ b/src/server/api/system.ts @@ -0,0 +1,175 @@ +import { allApps, onChange } from '$apps' +import { Hype } from '@because/hype' +import { cpus, freemem, totalmem } from 'os' +import { statfsSync } from 'fs' + +export interface SystemMetrics { + cpu: number // 0-100 + ram: { used: number, total: number, percent: number } + disk: { used: number, total: number, percent: number } +} + +export interface UnifiedLogLine { + time: number + app: string + text: string +} + +const router = Hype.router() + +// Unified log buffer +const unifiedLogs: UnifiedLogLine[] = [] +const MAX_UNIFIED_LOGS = 200 +const unifiedLogListeners = new Set<(line: UnifiedLogLine) => void>() + +// Track last log counts per app for delta detection +const lastLogCounts = new Map() + +// CPU tracking +let lastCpuTimes: { idle: number, total: number } | null = null + +function getCpuUsage(): number { + const cpuList = cpus() + let idle = 0 + let total = 0 + + for (const cpu of cpuList) { + idle += cpu.times.idle + total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq + } + + if (!lastCpuTimes) { + lastCpuTimes = { idle, total } + return 0 + } + + const idleDiff = idle - lastCpuTimes.idle + const totalDiff = total - lastCpuTimes.total + + lastCpuTimes = { idle, total } + + if (totalDiff === 0) return 0 + return Math.round((1 - idleDiff / totalDiff) * 100) +} + +function getMemoryUsage(): { used: number, total: number, percent: number } { + const total = totalmem() + const free = freemem() + const used = total - free + return { + used, + total, + percent: Math.round((used / total) * 100), + } +} + +function getDiskUsage(): { used: number, total: number, percent: number } { + try { + const stats = statfsSync('/') + const total = stats.blocks * stats.bsize + const free = stats.bfree * stats.bsize + const used = total - free + return { + used, + total, + percent: Math.round((used / total) * 100), + } + } catch { + return { used: 0, total: 0, percent: 0 } + } +} + +// Get current system metrics +router.get('/metrics', c => { + const metrics: SystemMetrics = { + cpu: getCpuUsage(), + ram: getMemoryUsage(), + disk: getDiskUsage(), + } + return c.json(metrics) +}) + +// SSE stream for real-time metrics (updates every 2s) +router.sse('/metrics/stream', (send) => { + const sendMetrics = () => { + const metrics: SystemMetrics = { + cpu: getCpuUsage(), + ram: getMemoryUsage(), + disk: getDiskUsage(), + } + send(metrics) + } + + // Initial send + sendMetrics() + + // Update every 2 seconds + const interval = setInterval(sendMetrics, 2000) + + return () => clearInterval(interval) +}) + +// Get recent unified logs +router.get('/logs', c => { + const tail = c.req.query('tail') + const count = tail ? parseInt(tail, 10) : MAX_UNIFIED_LOGS + return c.json(unifiedLogs.slice(-count)) +}) + +// Clear unified logs +router.post('/logs/clear', c => { + unifiedLogs.length = 0 + return c.json({ ok: true }) +}) + +// SSE stream for unified logs +router.sse('/logs/stream', (send) => { + // Send existing logs first + for (const line of unifiedLogs.slice(-50)) { + send(line) + } + + // Subscribe to new logs + const listener = (line: UnifiedLogLine) => send(line) + unifiedLogListeners.add(listener) + + return () => unifiedLogListeners.delete(listener) +}) + +// Collect logs from all apps +function collectLogs() { + const apps = allApps() + + for (const app of apps) { + const logs = app.logs ?? [] + const lastCount = lastLogCounts.get(app.name) ?? 0 + + // Get new logs since last check + const newLogs = logs.slice(lastCount) + lastLogCounts.set(app.name, logs.length) + + for (const log of newLogs) { + const line: UnifiedLogLine = { + time: log.time, + app: app.name, + text: log.text, + } + + // Add to buffer + unifiedLogs.push(line) + if (unifiedLogs.length > MAX_UNIFIED_LOGS) { + unifiedLogs.shift() + } + + // Notify listeners + for (const listener of unifiedLogListeners) { + listener(line) + } + } + } +} + +// Subscribe to app changes to collect logs +onChange(collectLogs) + +export default router diff --git a/src/server/index.tsx b/src/server/index.tsx index 37cfd88..ef83514 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,12 +1,14 @@ import { allApps, initApps, TOES_URL } from '$apps' import appsRouter from './api/apps' import syncRouter from './api/sync' +import systemRouter from './api/system' import { Hype } from '@because/hype' const app = new Hype({ layout: false, logging: false }) app.route('/api/apps', appsRouter) app.route('/api/sync', syncRouter) +app.route('/api/system', systemRouter) // Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool port app.get('/tool/:tool', c => {