diff --git a/src/client/components/Dashboard.tsx b/src/client/components/Dashboard.tsx
index 4c436be..427179e 100644
--- a/src/client/components/Dashboard.tsx
+++ b/src/client/components/Dashboard.tsx
@@ -1,8 +1,9 @@
import { Styles } from '@because/forge'
-import { Modal } from './modal'
import { apps, isNarrow, selectedApp } from '../state'
-import { EmptyState, Layout } from '../styles'
+import { Layout } from '../styles'
import { AppDetail } from './AppDetail'
+import { DashboardLanding } from './DashboardLanding'
+import { Modal } from './modal'
import { Sidebar } from './Sidebar'
export function Dashboard({ render }: { render: () => void }) {
@@ -15,7 +16,7 @@ export function Dashboard({ render }: { render: () => void }) {
{selected ? (
) : (
- Select an app to view details
+
)}
diff --git a/src/client/components/DashboardLanding.tsx b/src/client/components/DashboardLanding.tsx
new file mode 100644
index 0000000..f81d539
--- /dev/null
+++ b/src/client/components/DashboardLanding.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useState } from 'hono/jsx'
+import { apps } from '../state'
+import {
+ DashboardContainer,
+ DashboardHeader,
+ DashboardSubtitle,
+ DashboardTitle,
+ StatCard,
+ StatLabel,
+ 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
+ Your personal web appliance
+
+
+
+
+ {regularApps.length}
+ Apps
+
+
+ {toolApps.length}
+ Tools
+
+
+ {runningApps.length}
+ Running
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx
index c3987e7..ba586cc 100644
--- a/src/client/components/Sidebar.tsx
+++ b/src/client/components/Sidebar.tsx
@@ -1,5 +1,6 @@
import { openNewAppModal } from '../modals'
import {
+ setSelectedApp,
setSidebarCollapsed,
sidebarCollapsed,
} from '../state'
@@ -7,6 +8,7 @@ import {
HamburgerButton,
HamburgerLine,
Logo,
+ LogoLink,
NewAppButton,
Sidebar as SidebarContainer,
SidebarFooter,
@@ -14,6 +16,11 @@ import {
import { AppSelector } from './AppSelector'
export function Sidebar({ render }: { render: () => void }) {
+ const goToDashboard = () => {
+ setSelectedApp(null)
+ render()
+ }
+
const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed)
render()
@@ -22,7 +29,11 @@ export function Sidebar({ render }: { render: () => void }) {
return (
- {!sidebarCollapsed && 🐾 Toes}
+ {!sidebarCollapsed && (
+
+ 🐾 Toes
+
+ )}
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/index.tsx b/src/client/index.tsx
index 33236b5..0495d55 100644
--- a/src/client/index.tsx
+++ b/src/client/index.tsx
@@ -45,7 +45,9 @@ narrowQuery.addEventListener('change', e => {
const events = new EventSource('/api/apps/stream')
events.onmessage = e => {
setApps(JSON.parse(e.data))
- const valid = selectedApp && apps.some(a => a.name === selectedApp)
- if (!valid && apps.length) setSelectedApp(apps[0]!.name)
+ // If selected app no longer exists, clear selection to show dashboard
+ if (selectedApp && !apps.some(a => a.name === selectedApp)) {
+ setSelectedApp(null)
+ }
render()
}
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 42a6847..965069e 100644
--- a/src/client/styles/index.ts
+++ b/src/client/styles/index.ts
@@ -1,15 +1,45 @@
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,
AppList,
AppSelectorChevron,
ClickableAppName,
+ DashboardContainer,
+ DashboardHeader,
+ DashboardSubtitle,
+ DashboardTitle,
HamburgerButton,
HamburgerLine,
HeaderActions,
Layout,
Logo,
+ LogoLink,
Main,
MainContent,
MainHeader,
@@ -19,6 +49,10 @@ export {
SectionTab,
Sidebar,
SidebarFooter,
+ StatCard,
+ StatLabel,
+ StatsGrid,
+ StatValue,
} from './layout'
export { LogLine, LogsContainer, LogTime } from './logs.tsx'
export {
diff --git a/src/client/styles/layout.ts b/src/client/styles/layout.ts
index 593e65b..c8d93f2 100644
--- a/src/client/styles/layout.ts
+++ b/src/client/styles/layout.ts
@@ -28,6 +28,18 @@ export const Logo = define('Logo', {
borderBottom: `1px solid ${theme('colors-border')}`,
})
+export const LogoLink = define('LogoLink', {
+ cursor: 'pointer',
+ borderRadius: theme('radius-md'),
+ padding: '4px 8px',
+ margin: '-4px -8px',
+ selectors: {
+ '&:hover': {
+ background: theme('colors-bgHover'),
+ },
+ },
+})
+
export const HamburgerButton = define('HamburgerButton', {
base: 'button',
background: 'none',
@@ -183,3 +195,58 @@ export const MainContent = define('MainContent', {
padding: '10px 24px',
overflow: 'auto',
})
+
+export const DashboardContainer = define('DashboardContainer', {
+ flex: 1,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 40,
+ gap: 40,
+})
+
+export const DashboardHeader = define('DashboardHeader', {
+ textAlign: 'center',
+})
+
+export const DashboardTitle = define('DashboardTitle', {
+ fontSize: 48,
+ fontWeight: 'bold',
+ margin: 0,
+ marginBottom: 8,
+})
+
+export const DashboardSubtitle = define('DashboardSubtitle', {
+ fontSize: 16,
+ color: theme('colors-textMuted'),
+ margin: 0,
+})
+
+export const StatsGrid = define('StatsGrid', {
+ display: 'flex',
+ gap: 24,
+})
+
+export const StatCard = define('StatCard', {
+ background: theme('colors-bgElement'),
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-md'),
+ padding: '24px 40px',
+ textAlign: 'center',
+ minWidth: 120,
+})
+
+export const StatValue = define('StatValue', {
+ fontSize: 36,
+ fontWeight: 'bold',
+ color: theme('colors-text'),
+ marginBottom: 4,
+})
+
+export const StatLabel = define('StatLabel', {
+ fontSize: 14,
+ color: theme('colors-textMuted'),
+ textTransform: 'uppercase',
+ letterSpacing: '0.05em',
+})
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 => {
diff --git a/src/server/shell.tsx b/src/server/shell.tsx
index 90a77bf..daf3879 100644
--- a/src/server/shell.tsx
+++ b/src/server/shell.tsx
@@ -2,6 +2,7 @@ export const Shell = () => (
Toes
+