Compare commits

..

5 Commits

Author SHA1 Message Date
c10ebe3c98 kill old processes on boot 2026-02-13 09:59:20 -08:00
2f4d4f5c19 new emoji 2026-02-13 09:40:07 -08:00
720c0e76fb dashboard 2026-02-13 09:02:21 -08:00
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
16 changed files with 968 additions and 46 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,40 +1,46 @@
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 { update } from '../update'
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
import { Vitals, initVitals } from './Vitals'
export function DashboardLanding() {
const regularApps = apps.filter(app => !app.tool)
const toolApps = apps.filter(app => app.tool)
const runningApps = apps.filter(app => app.state === 'running')
useEffect(() => {
initUnifiedLogs()
initVitals()
}, [])
return (
<DashboardContainer>
<DashboardHeader>
<DashboardTitle>🐾 Toes</DashboardTitle>
<DashboardSubtitle>Your personal web appliance</DashboardSubtitle>
{/*<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 />
<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

@ -0,0 +1,115 @@
import {
LogApp,
LogEntry,
LogsBody,
LogsHeader,
LogsSection,
LogsTitle,
LogText,
LogTimestamp,
} from '../styles'
import { update } from '../update'
export interface UnifiedLogLine {
time: number
app: string
text: string
}
const MAX_LOGS = 200
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+/)
|| 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 LogLineEntry({ 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>
)
}
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} />)
)}
</>
)
}
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>Logs</LogsTitle>
</LogsHeader>
<LogsBody id="unified-logs-body">
<LogsBodyContent />
</LogsBody>
</LogsSection>
)
}

View File

@ -0,0 +1,165 @@
import {
GaugeContainer,
GaugeSvg,
GaugeValue,
VitalCard,
VitalLabel,
VitalsSection,
} from '../styles'
import { update } from '../update'
interface AppMetrics {
cpu: number
mem: number
disk: number
}
interface SystemMetrics {
cpu: number
ram: { used: number, total: number, percent: number }
disk: { used: number, total: number, percent: number }
apps: Record<string, AppMetrics>
}
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
let _metrics: SystemMetrics = {
cpu: 0,
ram: { used: 0, total: 0, percent: 0 },
disk: { used: 0, total: 0, percent: 0 },
apps: {},
}
let _source: EventSource | undefined
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="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 VitalsContent() {
return (
<>
<VitalCard>
<VitalLabel>CPU</VitalLabel>
<Gauge value={_metrics.cpu} />
</VitalCard>
<VitalCard>
<VitalLabel>RAM</VitalLabel>
<Gauge value={_metrics.ram.percent} />
</VitalCard>
<VitalCard>
<VitalLabel>Disk</VitalLabel>
<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

@ -496,6 +496,21 @@ const EMOJI_KEYWORDS: Record<string, string[]> = {
'🎮': ['video game', 'controller', 'gaming', 'play'],
'🎰': ['slot machine', 'casino', 'gambling', 'jackpot'],
'🧩': ['puzzle', 'piece', 'jigsaw', 'game'],
'🎈': ['balloon', 'party', 'birthday'],
'🎉': ['party', 'popper', 'celebrate', 'tada', 'congratulations'],
'🎊': ['confetti', 'ball', 'celebrate'],
'🎃': ['jack-o-lantern', 'pumpkin', 'halloween'],
'🎄': ['christmas', 'tree', 'holiday', 'xmas'],
'🎋': ['tanabata', 'tree', 'japanese', 'wish'],
'🎍': ['pine', 'decoration', 'japanese', 'new year'],
'🎎': ['japanese dolls', 'hinamatsuri'],
'🎏': ['carp streamer', 'japanese', 'koinobori'],
'🎐': ['wind chime', 'japanese'],
'🎑': ['moon viewing', 'tsukimi', 'japanese'],
'🧧': ['red envelope', 'lucky', 'chinese'],
'🏮': ['lantern', 'red', 'japanese', 'festival'],
'🎁': ['gift', 'present', 'birthday', 'christmas', 'wrapped'],
'🎀': ['ribbon', 'bow', 'gift', 'decoration'],
// Travel
'🚗': ['car', 'vehicle', 'drive', 'automobile'],
@ -713,6 +728,126 @@ const EMOJI_KEYWORDS: Record<string, string[]> = {
'🦠': ['microbe', 'germ', 'bacteria', 'virus'],
'🧫': ['petri dish', 'bacteria', 'science'],
'🧪': ['test tube', 'science', 'chemistry', 'experiment'],
'📝': ['memo', 'note', 'pencil', 'write'],
'✏️': ['pencil', 'write', 'edit'],
'🖊️': ['pen', 'write', 'ballpoint'],
'🖋️': ['fountain pen', 'write', 'ink'],
'🖍️': ['crayon', 'draw', 'color'],
'📖': ['book', 'open', 'read'],
'📗': ['book', 'green', 'read'],
'📘': ['book', 'blue', 'read'],
'📙': ['book', 'orange', 'read'],
'📕': ['book', 'red', 'closed', 'read'],
'📓': ['notebook', 'write'],
'📔': ['notebook', 'decorative'],
'📒': ['ledger', 'notebook', 'yellow'],
'📚': ['books', 'stack', 'read', 'library'],
'📰': ['newspaper', 'news', 'paper', 'press'],
'🗞️': ['newspaper', 'rolled', 'news'],
'📜': ['scroll', 'document', 'ancient', 'paper'],
'📃': ['page', 'curl', 'document'],
'📄': ['page', 'document', 'paper'],
'📑': ['bookmark', 'tabs', 'document'],
'📊': ['bar chart', 'graph', 'statistics', 'data'],
'📈': ['chart', 'increasing', 'trending up', 'stock'],
'📉': ['chart', 'decreasing', 'trending down', 'stock'],
'📆': ['calendar', 'date', 'schedule'],
'📅': ['calendar', 'date'],
'🗓️': ['spiral calendar', 'date', 'schedule'],
'📇': ['card index', 'rolodex'],
'🗃️': ['card file box', 'organize'],
'🗳️': ['ballot box', 'vote', 'election'],
'🗄️': ['file cabinet', 'storage', 'office'],
'📋': ['clipboard', 'paste', 'list'],
'📌': ['pushpin', 'pin', 'location'],
'📍': ['round pushpin', 'pin', 'location'],
'📎': ['paperclip', 'clip', 'attach'],
'🖇️': ['linked paperclips', 'attach'],
'📏': ['straight ruler', 'measure'],
'📐': ['triangular ruler', 'measure', 'geometry'],
'✂️': ['scissors', 'cut', 'snip'],
'🗂️': ['card index dividers', 'organize', 'tabs'],
'🗑️': ['wastebasket', 'trash', 'delete', 'bin', 'garbage'],
'✉️': ['envelope', 'mail', 'letter', 'email'],
'📧': ['email', 'mail', 'letter', 'at'],
'📨': ['incoming envelope', 'mail', 'receive'],
'📩': ['envelope', 'arrow', 'mail', 'send'],
'📤': ['outbox', 'sent', 'mail'],
'📥': ['inbox', 'receive', 'mail'],
'📦': ['package', 'box', 'delivery', 'shipping'],
'📫': ['mailbox', 'mail', 'closed', 'flag'],
'📪': ['mailbox', 'empty', 'closed'],
'📬': ['mailbox', 'mail', 'open', 'flag'],
'📭': ['mailbox', 'open', 'empty'],
'📮': ['postbox', 'mail', 'red'],
'🔒': ['lock', 'locked', 'secure', 'closed'],
'🔓': ['unlock', 'open', 'unlocked'],
'🔏': ['lock', 'pen', 'privacy'],
'🔐': ['lock', 'key', 'secure'],
'🔑': ['key', 'lock', 'password', 'access'],
'🗝️': ['old key', 'vintage', 'antique'],
'🪞': ['mirror', 'reflection'],
'🪟': ['window', 'glass', 'frame'],
'🛋️': ['couch', 'sofa', 'furniture'],
'🪑': ['chair', 'seat', 'furniture'],
'🚪': ['door', 'entrance', 'exit'],
'🛏️': ['bed', 'sleep', 'bedroom'],
'🧸': ['teddy bear', 'toy', 'stuffed'],
'🪆': ['nesting dolls', 'matryoshka', 'russian'],
'🖼️': ['frame', 'picture', 'painting', 'art'],
'🛒': ['shopping cart', 'grocery', 'buy'],
'🛍️': ['shopping bags', 'buy', 'retail'],
'🏷️': ['label', 'tag', 'price', 'sale'],
'🧴': ['lotion', 'bottle', 'sunscreen'],
'🧷': ['safety pin', 'diaper'],
'🧹': ['broom', 'sweep', 'clean'],
'🧺': ['basket', 'laundry'],
'🧻': ['toilet paper', 'roll', 'bathroom'],
'🪣': ['bucket', 'pail', 'water'],
'🧼': ['soap', 'wash', 'clean'],
'🧽': ['sponge', 'clean', 'wash'],
'🪥': ['toothbrush', 'dental', 'brush'],
'🪒': ['razor', 'shave'],
'🪄': ['magic wand', 'wizard', 'spell'],
'👑': ['crown', 'king', 'queen', 'royal'],
'👒': ['hat', 'woman', 'sun'],
'🎩': ['top hat', 'formal', 'gentleman', 'magic'],
'🎓': ['graduation', 'cap', 'school', 'diploma'],
'🧢': ['cap', 'hat', 'baseball'],
'⛑️': ['rescue helmet', 'safety', 'hard hat'],
'👓': ['glasses', 'eyeglasses', 'spectacles'],
'🕶️': ['sunglasses', 'cool', 'dark'],
'🥽': ['goggles', 'swim', 'lab', 'safety'],
'💼': ['briefcase', 'business', 'work'],
'👜': ['handbag', 'purse', 'bag'],
'👛': ['purse', 'wallet', 'money'],
'👝': ['clutch bag', 'pouch'],
'🎒': ['backpack', 'school', 'bag'],
'🧳': ['luggage', 'suitcase', 'travel'],
'👞': ['shoe', 'man', 'dress'],
'👟': ['sneaker', 'running', 'shoe', 'athletic'],
'🥾': ['hiking boot', 'shoe', 'outdoor'],
'🥿': ['flat shoe', 'ballet'],
'👠': ['high heel', 'shoe', 'woman'],
'👡': ['sandal', 'shoe', 'woman'],
'🩰': ['ballet shoes', 'dance'],
'👢': ['boot', 'woman', 'shoe'],
'👗': ['dress', 'woman', 'fashion'],
'👔': ['necktie', 'tie', 'formal', 'business'],
'👕': ['t-shirt', 'shirt', 'clothes'],
'👖': ['jeans', 'pants', 'denim'],
'🧣': ['scarf', 'winter', 'warm'],
'🧤': ['gloves', 'winter', 'hands'],
'🧥': ['coat', 'jacket', 'winter'],
'🧦': ['socks', 'feet', 'warm'],
'👙': ['bikini', 'swim', 'beach'],
'👘': ['kimono', 'japanese', 'clothes'],
'🥻': ['sari', 'indian', 'clothes'],
'🩱': ['swimsuit', 'one piece'],
'🩲': ['swim briefs', 'underwear'],
'🩳': ['shorts', 'pants'],
'💄': ['lipstick', 'makeup', 'cosmetic'],
'💍': ['ring', 'diamond', 'wedding', 'engagement'],
// Symbols
'❤️': ['heart', 'love', 'red'],
@ -1005,6 +1140,12 @@ const EMOJI_KEYWORDS: Record<string, string[]> = {
'🕥': ['clock', '10:30', 'time'],
'🕦': ['clock', '11:30', 'time'],
'🕧': ['clock', '12:30', 'time'],
'🏁': ['checkered flag', 'race', 'finish'],
'🚩': ['red flag', 'triangular', 'warning'],
'🎌': ['crossed flags', 'japanese', 'celebration'],
'🏳️': ['white flag', 'surrender', 'peace'],
'🏴': ['black flag', 'pirate'],
'🏳️‍🌈': ['rainbow flag', 'pride', 'lgbtq'],
// Nature (excluding duplicates that exist in other categories)
'🌸': ['cherry blossom', 'flower', 'spring', 'sakura'],
@ -1083,10 +1224,10 @@ const EMOJI_CATEGORIES: Record<Category, string[]> = {
'Gestures': ['👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶', '👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀', '👁️', '👅', '👄'],
'Animals': ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐻‍❄️', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🙈', '🙉', '🙊', '🐒', '🐔', '🐧', '🐦', '🐤', '🐣', '🐥', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗', '🐴', '🦄', '🐝', '🪱', '🐛', '🦋', '🐌', '🐞', '🐜', '🪰', '🪲', '🪳', '🦟', '🦗', '🕷️', '🦂', '🐢', '🐍', '🦎', '🦖', '🦕', '🐙', '🦑', '🦐', '🦞', '🦀', '🐡', '🐠', '🐟', '🐬', '🐳', '🐋', '🦈', '🐊', '🐅', '🐆', '🦓', '🦍', '🦧', '🦣', '🐘', '🦛', '🦏', '🐪', '🐫', '🦒', '🦘', '🦬', '🐃', '🐂', '🐄', '🐎', '🐖', '🐏', '🐑', '🦙', '🐐', '🦌', '🐕', '🐩', '🦮', '🐕‍🦺', '🐈', '🐈‍⬛', '🪶', '🐓', '🦃', '🦤', '🦚', '🦜', '🦢', '🦩', '🕊️', '🐇', '🦝', '🦨', '🦡', '🦫', '🦦', '🦥', '🐁', '🐀', '🐿️', '🦔'],
'Food': ['🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅', '🍆', '🥑', '🥦', '🥬', '🥒', '🌶️', '🫑', '🌽', '🥕', '🫒', '🧄', '🧅', '🥔', '🍠', '🥐', '🥯', '🍞', '🥖', '🥨', '🧀', '🥚', '🍳', '🧈', '🥞', '🧇', '🥓', '🥩', '🍗', '🍖', '🦴', '🌭', '🍔', '🍟', '🍕', '🫓', '🥪', '🥙', '🧆', '🌮', '🌯', '🫔', '🥗', '🥘', '🫕', '🥫', '🍝', '🍜', '🍲', '🍛', '🍣', '🍱', '🥟', '🦪', '🍤', '🍙', '🍚', '🍘', '🍥', '🥠', '🥮', '🍢', '🍡', '🍧', '🍨', '🍦', '🥧', '🧁', '🍰', '🎂', '🍮', '🍭', '🍬', '🍫', '🍿', '🍩', '🍪', '🌰', '🥜', '🍯', '🥛', '🍼', '🫖', '☕', '🍵', '🧃', '🥤', '🧋', '🍶', '🍺', '🍻', '🥂', '🍷', '🥃', '🍸', '🍹', '🧉', '🍾', '🧊'],
'Activities': ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍', '🏏', '🪃', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿', '🥊', '🥋', '🎽', '🛹', '🛼', '🛷', '⛸️', '🥌', '🎿', '⛷️', '🏂', '🪂', '🏋️', '🤼', '🤸', '⛹️', '🤺', '🤾', '🏌️', '🏇', '🧘', '🏄', '🏊', '🤽', '🚣', '🧗', '🚵', '🚴', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖️', '🏵️', '🎗️', '🎫', '🎟️', '🎪', '🎭', '🎨', '🎬', '🎤', '🎧', '🎼', '🎹', '🥁', '🪘', '🎷', '🎺', '🪗', '🎸', '🪕', '🎻', '🎲', '♟️', '🎯', '🎳', '🎮', '🎰', '🧩'],
'Activities': ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍', '🏏', '🪃', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿', '🥊', '🥋', '🎽', '🛹', '🛼', '🛷', '⛸️', '🥌', '🎿', '⛷️', '🏂', '🪂', '🏋️', '🤼', '🤸', '⛹️', '🤺', '🤾', '🏌️', '🏇', '🧘', '🏄', '🏊', '🤽', '🚣', '🧗', '🚵', '🚴', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖️', '🏵️', '🎗️', '🎫', '🎟️', '🎪', '🎭', '🎨', '🎬', '🎤', '🎧', '🎼', '🎹', '🥁', '🪘', '🎷', '🎺', '🪗', '🎸', '🪕', '🎻', '🎲', '♟️', '🎯', '🎳', '🎮', '🎰', '🧩', '🎈', '🎉', '🎊', '🎃', '🎄', '🎋', '🎍', '🎎', '🎏', '🎐', '🎑', '🧧', '🏮', '🎁', '🎀'],
'Travel': ['🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑', '🚒', '🚐', '🛻', '🚚', '🚛', '🚜', '🦯', '🦽', '🦼', '🛴', '🚲', '🛵', '🏍️', '🛺', '🚨', '🚔', '🚍', '🚘', '🚖', '🚡', '🚠', '🚟', '🚃', '🚋', '🚞', '🚝', '🚄', '🚅', '🚈', '🚂', '🚆', '🚇', '🚊', '🚉', '✈️', '🛫', '🛬', '🛩️', '💺', '🛰️', '🚀', '🛸', '🚁', '🛶', '⛵', '🚤', '🛥️', '🛳️', '⛴️', '🚢', '⚓', '🪝', '⛽', '🚧', '🚦', '🚥', '🚏', '🗺️', '🗿', '🗽', '🗼', '🏰', '🏯', '🏟️', '🎡', '🎢', '🎠', '⛲', '⛱️', '🏖️', '🏝️', '🏜️', '🌋', '⛰️', '🏔️', '🗻', '🏕️', '⛺', '🛖', '🏠', '🏡', '🏘️', '🏚️', '🏗️', '🏭', '🏢', '🏬', '🏣', '🏤', '🏥', '🏦', '🏨', '🏪', '🏫', '🏩', '💒', '🏛️', '⛪', '🕌', '🕍', '🛕', '🕋', '⛩️'],
'Objects': ['⌚', '📱', '📲', '💻', '⌨️', '🖥️', '🖨️', '🖱️', '🖲️', '🕹️', '🗜️', '💽', '💾', '💿', '📀', '📼', '📷', '📸', '📹', '🎥', '📽️', '🎞️', '📞', '☎️', '📟', '📠', '📺', '📻', '🎙️', '🎚️', '🎛️', '🧭', '⏱️', '⏲️', '⏰', '🕰️', '⌛', '⏳', '📡', '🔋', '🔌', '💡', '🔦', '🕯️', '🪔', '🧯', '🛢️', '💸', '💵', '💴', '💶', '💷', '🪙', '💰', '💳', '💎', '⚖️', '🪜', '🧰', '🪛', '🔧', '🔨', '⚒️', '🛠️', '⛏️', '🪚', '🔩', '⚙️', '🪤', '🧱', '⛓️', '🧲', '🔫', '💣', '🧨', '🪓', '🔪', '🗡️', '⚔️', '🛡️', '🚬', '⚰️', '🪦', '⚱️', '🏺', '🔮', '📿', '🧿', '💈', '⚗️', '🔭', '🔬', '🕳️', '🩹', '🩺', '💊', '💉', '🩸', '🧬', '🦠', '🧫', '🧪'],
'Symbols': ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '🆔', '⚛️', '🉑', '☢️', '☣️', '📴', '📳', '🈶', '🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐', '㊙️', '㊗️', '🈴', '🈵', '🈹', '🈲', '🅰️', '🅱️', '🆎', '🆑', '🅾️', '🆘', '❌', '⭕', '🛑', '⛔', '📛', '🚫', '💯', '💢', '♨️', '🚷', '🚯', '🚳', '🚱', '🔞', '📵', '🚭', '❗', '❕', '❓', '❔', '‼️', '⁉️', '🔅', '🔆', '〽️', '⚠️', '🚸', '🔱', '⚜️', '🔰', '♻️', '✅', '🈯', '💹', '❇️', '✳️', '❎', '🌐', '💠', 'Ⓜ️', '🌀', '💤', '🏧', '🚾', '♿', '🅿️', '🛗', '🈳', '🈂️', '🛂', '🛃', '🛄', '🛅', '🚹', '🚺', '🚼', '⚧️', '🚻', '🚮', '🎦', '📶', '🈁', '🔣', '', '🔤', '🔡', '🔠', '🆖', '🆗', '🆙', '🆒', '🆕', '🆓', '0⃣', '1⃣', '2⃣', '3⃣', '4⃣', '5⃣', '6⃣', '7⃣', '8⃣', '9⃣', '🔟', '🔢', '#️⃣', '*️⃣', '⏏️', '▶️', '⏸️', '⏯️', '⏹️', '⏺️', '⏭️', '⏮️', '⏩', '⏪', '⏫', '⏬', '◀️', '🔼', '🔽', '➡️', '⬅️', '⬆️', '⬇️', '↗️', '↘️', '↙️', '↖️', '↕️', '↔️', '↪️', '↩️', '⤴️', '⤵️', '🔀', '🔁', '🔂', '🔄', '🔃', '🎵', '🎶', '', '', '➗', '✖️', '♾️', '💲', '💱', '™️', '©️', '®️', '〰️', '➰', '➿', '🔚', '🔙', '🔛', '🔝', '🔜', '✔️', '☑️', '🔘', '🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '⚫', '⚪', '🟤', '🔺', '🔻', '🔸', '🔹', '🔶', '🔷', '🔳', '🔲', '▪️', '▫️', '◾', '◽', '◼️', '◻️', '🟥', '🟧', '🟨', '🟩', '🟦', '🟪', '⬛', '⬜', '🟫', '🔈', '🔇', '🔉', '🔊', '🔔', '🔕', '📣', '📢', '👁️‍🗨️', '💬', '💭', '🗯️', '♠️', '♣️', '♥️', '♦️', '🃏', '🎴', '🀄', '🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕛', '🕜', '🕝', '🕞', '🕟', '🕠', '🕡', '🕢', '🕣', '🕤', '🕥', '🕦', '🕧'],
'Objects': ['⌚', '📱', '📲', '💻', '⌨️', '🖥️', '🖨️', '🖱️', '🖲️', '🕹️', '🗜️', '💽', '💾', '💿', '📀', '📼', '📷', '📸', '📹', '🎥', '📽️', '🎞️', '📞', '☎️', '📟', '📠', '📺', '📻', '🎙️', '🎚️', '🎛️', '🧭', '⏱️', '⏲️', '⏰', '🕰️', '⌛', '⏳', '📡', '🔋', '🔌', '💡', '🔦', '🕯️', '🪔', '🧯', '🛢️', '💸', '💵', '💴', '💶', '💷', '🪙', '💰', '💳', '💎', '⚖️', '🪜', '🧰', '🪛', '🔧', '🔨', '⚒️', '🛠️', '⛏️', '🪚', '🔩', '⚙️', '🪤', '🧱', '⛓️', '🧲', '🔫', '💣', '🧨', '🪓', '🔪', '🗡️', '⚔️', '🛡️', '🚬', '⚰️', '🪦', '⚱️', '🏺', '🔮', '📿', '🧿', '💈', '⚗️', '🔭', '🔬', '🕳️', '🩹', '🩺', '💊', '💉', '🩸', '🧬', '🦠', '🧫', '🧪', '📝', '✏️', '🖊️', '🖋️', '🖍️', '📖', '📗', '📘', '📙', '📕', '📓', '📔', '📒', '📚', '📰', '🗞️', '📜', '📃', '📄', '📑', '📊', '📈', '📉', '📆', '📅', '🗓️', '📇', '🗃️', '🗳️', '🗄️', '📋', '📌', '📍', '📎', '🖇️', '📏', '📐', '✂️', '🗂️', '🗑️', '✉️', '📧', '📨', '📩', '📤', '📥', '📦', '📫', '📪', '📬', '📭', '📮', '🔒', '🔓', '🔏', '🔐', '🔑', '🗝️', '🪞', '🪟', '🛋️', '🪑', '🚪', '🛏️', '🧸', '🪆', '🖼️', '🛒', '🛍️', '🏷️', '🧴', '🧷', '🧹', '🧺', '🧻', '🪣', '🧼', '🧽', '🪥', '🪒', '🪄', '👑', '👒', '🎩', '🎓', '🧢', '⛑️', '👓', '🕶️', '🥽', '💼', '👜', '👛', '👝', '🎒', '🧳', '👞', '👟', '🥾', '🥿', '👠', '👡', '🩰', '👢', '👗', '👔', '👕', '👖', '🧣', '🧤', '🧥', '🧦', '👙', '👘', '🥻', '🩱', '🩲', '🩳', '💄', '💍'],
'Symbols': ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '🆔', '⚛️', '🉑', '☢️', '☣️', '📴', '📳', '🈶', '🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐', '㊙️', '㊗️', '🈴', '🈵', '🈹', '🈲', '🅰️', '🅱️', '🆎', '🆑', '🅾️', '🆘', '❌', '⭕', '🛑', '⛔', '📛', '🚫', '💯', '💢', '♨️', '🚷', '🚯', '🚳', '🚱', '🔞', '📵', '🚭', '❗', '❕', '❓', '❔', '‼️', '⁉️', '🔅', '🔆', '〽️', '⚠️', '🚸', '🔱', '⚜️', '🔰', '♻️', '✅', '🈯', '💹', '❇️', '✳️', '❎', '🌐', '💠', 'Ⓜ️', '🌀', '💤', '🏧', '🚾', '♿', '🅿️', '🛗', '🈳', '🈂️', '🛂', '🛃', '🛄', '🛅', '🚹', '🚺', '🚼', '⚧️', '🚻', '🚮', '🎦', '📶', '🈁', '🔣', '', '🔤', '🔡', '🔠', '🆖', '🆗', '🆙', '🆒', '🆕', '🆓', '0⃣', '1⃣', '2⃣', '3⃣', '4⃣', '5⃣', '6⃣', '7⃣', '8⃣', '9⃣', '🔟', '🔢', '#️⃣', '*️⃣', '⏏️', '▶️', '⏸️', '⏯️', '⏹️', '⏺️', '⏭️', '⏮️', '⏩', '⏪', '⏫', '⏬', '◀️', '🔼', '🔽', '➡️', '⬅️', '⬆️', '⬇️', '↗️', '↘️', '↙️', '↖️', '↕️', '↔️', '↪️', '↩️', '⤴️', '⤵️', '🔀', '🔁', '🔂', '🔄', '🔃', '🎵', '🎶', '', '', '➗', '✖️', '♾️', '💲', '💱', '™️', '©️', '®️', '〰️', '➰', '➿', '🔚', '🔙', '🔛', '🔝', '🔜', '✔️', '☑️', '🔘', '🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '⚫', '⚪', '🟤', '🔺', '🔻', '🔸', '🔹', '🔶', '🔷', '🔳', '🔲', '▪️', '▫️', '◾', '◽', '◼️', '◻️', '🟥', '🟧', '🟨', '🟩', '🟦', '🟪', '⬛', '⬜', '🟫', '🔈', '🔇', '🔉', '🔊', '🔔', '🔕', '📣', '📢', '👁️‍🗨️', '💬', '💭', '🗯️', '♠️', '♣️', '♥️', '♦️', '🃏', '🎴', '🀄', '🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕛', '🕜', '🕝', '🕞', '🕟', '🕠', '🕡', '🕢', '🕣', '🕤', '🕥', '🕦', '🕧', '🏁', '🚩', '🎌', '🏳️', '🏴', '🏳️‍🌈'],
'Nature': ['🌸', '💮', '🏵️', '🌹', '🥀', '🌺', '🌻', '🌼', '🌷', '🌱', '🪴', '🌲', '🌳', '🌴', '🌵', '🌾', '🌿', '☘️', '🍀', '🍁', '🍂', '🍃', '🍄', '🌰', '🦀', '🦞', '🦐', '🦑', '🌍', '🌎', '🌏', '🌐', '🪨', '🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘', '🌙', '🌚', '🌛', '🌜', '☀️', '🌝', '🌞', '🪐', '⭐', '🌟', '🌠', '🌌', '☁️', '⛅', '⛈️', '🌤️', '🌥️', '🌦️', '🌧️', '🌨️', '🌩️', '🌪️', '🌫️', '🌬️', '🌀', '🌈', '🌂', '☂️', '☔', '⛱️', '⚡', '❄️', '☃️', '⛄', '☄️', '🔥', '💧', '🌊'],
}

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

@ -0,0 +1,142 @@
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',
})
export const GaugeContainer = define('GaugeContainer', {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
})
export const GaugeSvg = define('GaugeSvg', {
base: 'svg',
width: 120,
height: 70,
overflow: 'visible',
})
export const GaugeValue = define('GaugeValue', {
fontSize: 20,
fontWeight: 'bold',
fontFamily: theme('fonts-mono'),
color: theme('colors-text'),
marginTop: 20,
marginLeft: 5,
})
// 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,4 +1,22 @@
export { ActionBar, Button, NewAppButton } from './buttons'
export {
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,
@ -40,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,

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

@ -0,0 +1,264 @@
import { allApps, APPS_DIR, onChange } from '$apps'
import { Hype } from '@because/hype'
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 {
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 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: used > 0 ? Math.max(1, Math.round((used / total) * 100)) : 0,
}
}
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 }
}
}
// 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)
})
// 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(),
apps: getAppMetrics(),
}
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

@ -96,7 +96,8 @@ export function readLogs(appName: string, date?: string, tail?: number): string[
return lines
}
export function initApps() {
export async function initApps() {
await killStaleProcesses()
initPortPool()
setupShutdownHandlers()
rotateLogs()
@ -466,6 +467,25 @@ function handleHealthCheckFailure(app: App) {
}
}
async function killStaleProcesses() {
const result = Bun.spawnSync(['lsof', '-ti', `:${MIN_PORT - 1}-${MAX_PORT}`])
const output = result.stdout.toString().trim()
if (!output) return
const pids = output.split('\n').map(Number).filter(pid => pid && pid !== process.pid)
if (pids.length === 0) return
hostLog(`Found ${pids.length} stale process(es) on ports ${MIN_PORT - 1}-${MAX_PORT}`)
for (const pid of pids) {
try {
process.kill(pid, 'SIGKILL')
hostLog(`Killed stale process ${pid}`)
} catch {
// Process already gone
}
}
}
function initPortPool() {
_availablePorts.length = 0
for (let port = MIN_PORT; port <= MAX_PORT; port++) {

View File

@ -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 })
const app = new Hype({ layout: false, logging: !!process.env.DEBUG })
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 => {
@ -51,7 +53,7 @@ app.all('/api/tools/:tool/:path{.+}', async c => {
})
})
initApps()
await initApps()
export default {
...app.defaults,

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