Compare commits

..

4 Commits

Author SHA1 Message Date
Chris Wanstrath
8b31fa3f19
Merge pull request #5 from defunkt/claude/add-dashboard-landing-8UBAd
Add dashboard landing page with app statistics
2026-02-13 08:46:20 -08:00
Claude
50e5c97beb
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
2026-02-13 16:41:21 +00:00
543b5d08bc
favicon 2026-02-13 16:41:20 +00:00
Claude
a91f400100
Add dashboard landing page with clickable logo navigation
The Toes logo now links to a system-wide dashboard view that shows
app and tool counts. This is the default view when first opening
the web app.

https://claude.ai/code/session_013L9HKHxMEoub76B1zuKive
2026-02-13 16:41:20 +00:00
12 changed files with 838 additions and 6 deletions

View File

@ -1,8 +1,9 @@
import { Styles } from '@because/forge' import { Styles } from '@because/forge'
import { Modal } from './modal'
import { apps, isNarrow, selectedApp } from '../state' import { apps, isNarrow, selectedApp } from '../state'
import { EmptyState, Layout } from '../styles' import { Layout } from '../styles'
import { AppDetail } from './AppDetail' import { AppDetail } from './AppDetail'
import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
export function Dashboard({ render }: { render: () => void }) { export function Dashboard({ render }: { render: () => void }) {
@ -15,7 +16,7 @@ export function Dashboard({ render }: { render: () => void }) {
{selected ? ( {selected ? (
<AppDetail app={selected} render={render} /> <AppDetail app={selected} render={render} />
) : ( ) : (
<EmptyState>Select an app to view details</EmptyState> <DashboardLanding />
)} )}
<Modal /> <Modal />
</Layout> </Layout>

View File

@ -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<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()
}, [])
// 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>
</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>
<Vitals cpu={metrics.cpu} ram={metrics.ram} disk={metrics.disk} />
<UnifiedLogs logs={logs} onClear={handleClearLogs} />
</DashboardContainer>
)
}

View File

@ -1,5 +1,6 @@
import { openNewAppModal } from '../modals' import { openNewAppModal } from '../modals'
import { import {
setSelectedApp,
setSidebarCollapsed, setSidebarCollapsed,
sidebarCollapsed, sidebarCollapsed,
} from '../state' } from '../state'
@ -7,6 +8,7 @@ import {
HamburgerButton, HamburgerButton,
HamburgerLine, HamburgerLine,
Logo, Logo,
LogoLink,
NewAppButton, NewAppButton,
Sidebar as SidebarContainer, Sidebar as SidebarContainer,
SidebarFooter, SidebarFooter,
@ -14,6 +16,11 @@ import {
import { AppSelector } from './AppSelector' import { AppSelector } from './AppSelector'
export function Sidebar({ render }: { render: () => void }) { export function Sidebar({ render }: { render: () => void }) {
const goToDashboard = () => {
setSelectedApp(null)
render()
}
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed) setSidebarCollapsed(!sidebarCollapsed)
render() render()
@ -22,7 +29,11 @@ export function Sidebar({ render }: { render: () => void }) {
return ( return (
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}> <SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
<Logo> <Logo>
{!sidebarCollapsed && <span>🐾 Toes</span>} {!sidebarCollapsed && (
<LogoLink onClick={goToDashboard} title="Go to dashboard">
🐾 Toes
</LogoLink>
)}
<HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}> <HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
<HamburgerLine /> <HamburgerLine />
<HamburgerLine /> <HamburgerLine />

View File

@ -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 (
<LogEntry>
<LogTimestamp>{formatTime(log.time)}</LogTimestamp>
<LogApp>{log.app}</LogApp>
<LogText style={statusColor ? { color: statusColor } : undefined}>
{log.text}
</LogText>
</LogEntry>
)
}
export function UnifiedLogs({ logs, onClear }: UnifiedLogsProps) {
const bodyRef = useRef<HTMLDivElement>(null)
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
if (bodyRef.current) {
bodyRef.current.scrollTop = bodyRef.current.scrollHeight
}
}, [logs.length])
return (
<LogsSection>
<LogsHeader>
<LogsTitle>Activity</LogsTitle>
<LogsClearButton onClick={onClear}>Clear</LogsClearButton>
</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>
</LogsSection>
)
}

View File

@ -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 (
<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>
<GaugeValue>{value}%</GaugeValue>
</GaugeContainer>
)
}
function BarGauge({ value }: { value: number }) {
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} />
</VitalCard>
<VitalCard>
<VitalLabel>RAM</VitalLabel>
<BarGauge value={ram.percent} />
</VitalCard>
<VitalCard>
<VitalLabel>Disk</VitalLabel>
<CircleGauge value={disk.percent} />
</VitalCard>
</VitalsSection>
)
}

View File

@ -45,7 +45,9 @@ narrowQuery.addEventListener('change', e => {
const events = new EventSource('/api/apps/stream') const events = new EventSource('/api/apps/stream')
events.onmessage = e => { events.onmessage = e => {
setApps(JSON.parse(e.data)) setApps(JSON.parse(e.data))
const valid = selectedApp && apps.some(a => a.name === selectedApp) // If selected app no longer exists, clear selection to show dashboard
if (!valid && apps.length) setSelectedApp(apps[0]!.name) if (selectedApp && !apps.some(a => a.name === selectedApp)) {
setSelectedApp(null)
}
render() render()
} }

View File

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

View File

@ -1,15 +1,45 @@
export { ActionBar, Button, NewAppButton } from './buttons' 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 { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from './forms'
export { export {
AppItem, AppItem,
AppList, AppList,
AppSelectorChevron, AppSelectorChevron,
ClickableAppName, ClickableAppName,
DashboardContainer,
DashboardHeader,
DashboardSubtitle,
DashboardTitle,
HamburgerButton, HamburgerButton,
HamburgerLine, HamburgerLine,
HeaderActions, HeaderActions,
Layout, Layout,
Logo, Logo,
LogoLink,
Main, Main,
MainContent, MainContent,
MainHeader, MainHeader,
@ -19,6 +49,10 @@ export {
SectionTab, SectionTab,
Sidebar, Sidebar,
SidebarFooter, SidebarFooter,
StatCard,
StatLabel,
StatsGrid,
StatValue,
} from './layout' } from './layout'
export { LogLine, LogsContainer, LogTime } from './logs.tsx' export { LogLine, LogsContainer, LogTime } from './logs.tsx'
export { export {

View File

@ -28,6 +28,18 @@ export const Logo = define('Logo', {
borderBottom: `1px solid ${theme('colors-border')}`, 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', { export const HamburgerButton = define('HamburgerButton', {
base: 'button', base: 'button',
background: 'none', background: 'none',
@ -183,3 +195,58 @@ export const MainContent = define('MainContent', {
padding: '10px 24px', padding: '10px 24px',
overflow: 'auto', 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',
})

175
src/server/api/system.ts Normal file
View File

@ -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<string, number>()
// 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

View File

@ -1,12 +1,14 @@
import { allApps, initApps, TOES_URL } from '$apps' import { allApps, initApps, TOES_URL } from '$apps'
import appsRouter from './api/apps' import appsRouter from './api/apps'
import syncRouter from './api/sync' import syncRouter from './api/sync'
import systemRouter from './api/system'
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
const app = new Hype({ layout: false, logging: false }) const app = new Hype({ layout: false, logging: false })
app.route('/api/apps', appsRouter) app.route('/api/apps', appsRouter)
app.route('/api/sync', syncRouter) app.route('/api/sync', syncRouter)
app.route('/api/system', systemRouter)
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool port // Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool port
app.get('/tool/:tool', c => { app.get('/tool/:tool', c => {

View File

@ -2,6 +2,7 @@ export const Shell = () => (
<html> <html>
<head> <head>
<title>Toes</title> <title>Toes</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐾</text></svg>" />
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<style dangerouslySetInnerHTML={{ __html: `body{margin:0}` }} /> <style dangerouslySetInnerHTML={{ __html: `body{margin:0}` }} />