From c7f8f09ba94727add5da62bcb9171b8a6608096d Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 1 Mar 2026 09:35:05 -0800 Subject: [PATCH 1/5] Add global field to filter tool tabs --- src/client/components/AppDetail.tsx | 4 ++-- src/client/components/Nav.tsx | 4 ++-- src/client/index.tsx | 2 +- src/server/api/apps.ts | 4 ++-- src/server/apps.ts | 6 ++++-- src/shared/types.ts | 1 + 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/client/components/AppDetail.tsx b/src/client/components/AppDetail.tsx index 811ae64..f99da5b 100644 --- a/src/client/components/AppDetail.tsx +++ b/src/client/components/AppDetail.tsx @@ -45,8 +45,8 @@ const OpenEmojiPicker = define('OpenEmojiPicker', { }) export function AppDetail({ app, render }: { app: App, render: () => void }) { - // Find all tools - const tools = apps.filter(a => a.tool) + // Find global tools (shown as tabs on every app) + const tools = apps.filter(a => a.tool && a.global) const selectedTab = getSelectedTab(app.name) return ( diff --git a/src/client/components/Nav.tsx b/src/client/components/Nav.tsx index 516f106..d6d4942 100644 --- a/src/client/components/Nav.tsx +++ b/src/client/components/Nav.tsx @@ -16,8 +16,8 @@ export function Nav({ app, render }: { app: App; render: () => void }) { navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`) } - // Find all tools - const tools = apps.filter(a => a.tool) + // Find global tools (shown as tabs on every app) + const tools = apps.filter(a => a.tool && a.global) const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1)) return ( diff --git a/src/client/index.tsx b/src/client/index.tsx index 209b405..e004c2f 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -10,7 +10,7 @@ const render = () => { renderApp(, document.getElementById('app')!) // Update tool iframes after DOM settles requestAnimationFrame(() => { - const tools = apps.filter(a => a.tool) + const tools = apps.filter(a => a.tool && a.global) updateToolIframes(getSelectedTab(selectedApp), tools, selectedApp) }) } diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 9c136c5..6c9ee6c 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -26,9 +26,9 @@ function convert(app: BackendApp): SharedApp { router.sse('/stream', (send) => { const broadcast = () => { const apps: SharedApp[] = allApps().map(({ - name, state, icon, error, port, started, logs, tool, tunnelEnabled, tunnelUrl + name, state, icon, error, port, started, logs, tool, global: global_, tunnelEnabled, tunnelUrl }) => ({ - name, state, icon, error, port, started, logs, tool, tunnelEnabled, tunnelUrl, + name, state, icon, error, port, started, logs, tool, global: global_, tunnelEnabled, tunnelUrl, })) send(apps) } diff --git a/src/server/apps.ts b/src/server/apps.ts index 2119c1a..210325d 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -159,7 +159,8 @@ export function registerApp(dir: string) { const state: AppState = error ? 'invalid' : 'stopped' const icon = pkg.toes?.icon ?? DEFAULT_EMOJI const tool = pkg.toes?.tool - _apps.set(dir, { name: dir, state, icon, error, tool }) + const global_ = pkg.toes?.global + _apps.set(dir, { name: dir, state, icon, error, tool, global: global_ }) update() emit({ type: 'app:create', app: dir }) if (!error) { @@ -379,7 +380,8 @@ function discoverApps() { const state: AppState = error ? 'invalid' : 'stopped' const icon = pkg.toes?.icon ?? DEFAULT_EMOJI const tool = pkg.toes?.tool - _apps.set(dir, { name: dir, state, icon, error, tool }) + const global_ = pkg.toes?.global + _apps.set(dir, { name: dir, state, icon, error, tool, global: global_ }) } update() } diff --git a/src/shared/types.ts b/src/shared/types.ts index c0fdd42..a24b714 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -28,6 +28,7 @@ export type App = { started?: number logs?: LogLine[] tool?: boolean | string + global?: boolean tunnelEnabled?: boolean tunnelUrl?: string } From 52cf99b56df351b1be3768c9545757555fc5f04e Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 1 Mar 2026 09:48:36 -0800 Subject: [PATCH 2/5] Replace `global` with `apps` and `dashboard` app properties --- src/client/components/AppDetail.tsx | 4 ++-- src/client/components/Nav.tsx | 4 ++-- src/client/index.tsx | 2 +- src/server/api/apps.ts | 4 ++-- src/server/apps.ts | 10 ++++++---- src/shared/types.ts | 3 ++- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/client/components/AppDetail.tsx b/src/client/components/AppDetail.tsx index f99da5b..77f5079 100644 --- a/src/client/components/AppDetail.tsx +++ b/src/client/components/AppDetail.tsx @@ -45,8 +45,8 @@ const OpenEmojiPicker = define('OpenEmojiPicker', { }) export function AppDetail({ app, render }: { app: App, render: () => void }) { - // Find global tools (shown as tabs on every app) - const tools = apps.filter(a => a.tool && a.global) + // Find tools that show on app pages (apps !== false) + const tools = apps.filter(a => a.tool && a.apps !== false) const selectedTab = getSelectedTab(app.name) return ( diff --git a/src/client/components/Nav.tsx b/src/client/components/Nav.tsx index d6d4942..930ceef 100644 --- a/src/client/components/Nav.tsx +++ b/src/client/components/Nav.tsx @@ -16,8 +16,8 @@ export function Nav({ app, render }: { app: App; render: () => void }) { navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`) } - // Find global tools (shown as tabs on every app) - const tools = apps.filter(a => a.tool && a.global) + // Find tools that show on app pages (apps !== false) + const tools = apps.filter(a => a.tool && a.apps !== false) const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1)) return ( diff --git a/src/client/index.tsx b/src/client/index.tsx index e004c2f..5d14d03 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -10,7 +10,7 @@ const render = () => { renderApp(, document.getElementById('app')!) // Update tool iframes after DOM settles requestAnimationFrame(() => { - const tools = apps.filter(a => a.tool && a.global) + const tools = apps.filter(a => a.tool && a.apps !== false) updateToolIframes(getSelectedTab(selectedApp), tools, selectedApp) }) } diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 6c9ee6c..863c193 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -26,9 +26,9 @@ function convert(app: BackendApp): SharedApp { router.sse('/stream', (send) => { const broadcast = () => { const apps: SharedApp[] = allApps().map(({ - name, state, icon, error, port, started, logs, tool, global: global_, tunnelEnabled, tunnelUrl + name, state, icon, error, port, started, logs, tool, apps: apps_, dashboard, tunnelEnabled, tunnelUrl }) => ({ - name, state, icon, error, port, started, logs, tool, global: global_, tunnelEnabled, tunnelUrl, + name, state, icon, error, port, started, logs, tool, apps: apps_, dashboard, tunnelEnabled, tunnelUrl, })) send(apps) } diff --git a/src/server/apps.ts b/src/server/apps.ts index 210325d..03de5df 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -159,8 +159,9 @@ export function registerApp(dir: string) { const state: AppState = error ? 'invalid' : 'stopped' const icon = pkg.toes?.icon ?? DEFAULT_EMOJI const tool = pkg.toes?.tool - const global_ = pkg.toes?.global - _apps.set(dir, { name: dir, state, icon, error, tool, global: global_ }) + const apps = pkg.toes?.apps + const dashboard = pkg.toes?.dashboard + _apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard }) update() emit({ type: 'app:create', app: dir }) if (!error) { @@ -380,8 +381,9 @@ function discoverApps() { const state: AppState = error ? 'invalid' : 'stopped' const icon = pkg.toes?.icon ?? DEFAULT_EMOJI const tool = pkg.toes?.tool - const global_ = pkg.toes?.global - _apps.set(dir, { name: dir, state, icon, error, tool, global: global_ }) + const apps = pkg.toes?.apps + const dashboard = pkg.toes?.dashboard + _apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard }) } update() } diff --git a/src/shared/types.ts b/src/shared/types.ts index a24b714..64984da 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -28,7 +28,8 @@ export type App = { started?: number logs?: LogLine[] tool?: boolean | string - global?: boolean + apps?: boolean + dashboard?: boolean tunnelEnabled?: boolean tunnelUrl?: string } From b99dd16343e7f80eda32a7c7b19d85e33a2cf34b Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 1 Mar 2026 09:57:13 -0800 Subject: [PATCH 3/5] Add dashboard view for global env vars --- apps/env/20260130-000000/index.tsx | 37 ++++++++++++++++++++++----- apps/env/20260130-000000/package.json | 3 ++- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/env/20260130-000000/index.tsx b/apps/env/20260130-000000/index.tsx index 945da19..fa74ff0 100644 --- a/apps/env/20260130-000000/index.tsx +++ b/apps/env/20260130-000000/index.tsx @@ -300,9 +300,35 @@ app.get('/', async c => { const appName = c.req.query('app') if (!appName) { + // Dashboard view: global env vars only + const globalVars = parseEnvFile(GLOBAL_ENV_PATH) + return c.html( - - Please specify an app name with ?app=<name> + + {globalVars.length === 0 ? ( + No global environment variables + ) : ( + + {globalVars.map(v => ( + + {v.key} + {'••••••••'} + + +
+ Delete +
+
+
+ ))} +
+ )} +
+ + + +
+ Global vars are available to all apps. Changes take effect on next app restart.
) } @@ -437,7 +463,6 @@ app.post('/delete', async c => { app.post('/set-global', async c => { const appName = c.req.query('app') - if (!appName) return c.text('Missing app', 400) const body = await c.req.parseBody() const key = String(body.key).trim().toUpperCase() @@ -455,17 +480,17 @@ app.post('/set-global', async c => { } writeEnvFile(GLOBAL_ENV_PATH, vars) - return c.redirect(`/?app=${appName}&tab=global`) + return c.redirect(appName ? `/?app=${appName}&tab=global` : '/') }) app.post('/delete-global', async c => { const appName = c.req.query('app') const key = c.req.query('key') - if (!appName || !key) return c.text('Missing app or key', 400) + if (!key) return c.text('Missing key', 400) const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key) writeEnvFile(GLOBAL_ENV_PATH, vars) - return c.redirect(`/?app=${appName}&tab=global`) + return c.redirect(appName ? `/?app=${appName}&tab=global` : '/') }) export default app.defaults diff --git a/apps/env/20260130-000000/package.json b/apps/env/20260130-000000/package.json index 6d5864e..cac9217 100644 --- a/apps/env/20260130-000000/package.json +++ b/apps/env/20260130-000000/package.json @@ -10,7 +10,8 @@ }, "toes": { "tool": ".env", - "icon": "🔑" + "icon": "🔑", + "dashboard": true }, "devDependencies": { "@types/bun": "latest" From 82c8fc42da35667c9c0d30c3e3abcad18c8016b8 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 1 Mar 2026 10:10:49 -0800 Subject: [PATCH 4/5] Add dashboard support for tool apps with iframe embedding --- apps/metrics/20260130-000000/index.tsx | 216 +++++++++++++++------ apps/metrics/20260130-000000/package.json | 3 +- bun.lock | 2 +- src/client/components/DashboardLanding.tsx | 47 ++++- src/client/index.tsx | 11 +- src/client/router.ts | 11 +- src/client/state.ts | 2 +- src/server/index.tsx | 4 +- 8 files changed, 213 insertions(+), 83 deletions(-) diff --git a/apps/metrics/20260130-000000/index.tsx b/apps/metrics/20260130-000000/index.tsx index 4850500..dd2dde9 100644 --- a/apps/metrics/20260130-000000/index.tsx +++ b/apps/metrics/20260130-000000/index.tsx @@ -55,6 +55,12 @@ interface ProcessMetrics { rss: number } +interface SystemMetrics { + cpu: number + ram: { used: number, total: number, percent: number } + disk: { used: number, total: number, percent: number } +} + // ============================================================================ // Process Metrics Collection // ============================================================================ @@ -402,6 +408,36 @@ const ChartWrapper = define('ChartWrapper', { height: '150px', }) +const GaugeLabel = define('GaugeLabel', { + fontSize: '13px', + fontWeight: 600, + color: theme('colors-textMuted'), + textTransform: 'uppercase', + letterSpacing: '0.5px', +}) + +const GaugeValueText = define('GaugeValueText', { + textAlign: 'center', + fontSize: '20px', + fontWeight: 'bold', + marginTop: '-4px', + color: theme('colors-text'), +}) + +const GaugesCard = define('GaugesCard', { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '8px', +}) + +const GaugesGrid = define('GaugesGrid', { + display: 'flex', + justifyContent: 'center', + gap: '40px', + padding: '20px 0', +}) + const NoDataMessage = define('NoDataMessage', { display: 'flex', alignItems: 'center', @@ -435,6 +471,14 @@ function formatRss(kb?: number): string { return `${(kb / 1024 / 1024).toFixed(2)} GB` } +async function fetchSystemMetrics(): Promise { + try { + const res = await fetch(`${TOES_URL}/api/system/metrics`) + if (res.ok) return await res.json() as SystemMetrics + } catch {} + return { cpu: 0, ram: { used: 0, total: 0, percent: 0 }, disk: { used: 0, total: 0, percent: 0 } } +} + function getStatusColor(state: string): string { switch (state) { case 'running': @@ -477,6 +521,107 @@ function Layout({ title, children }: LayoutProps) { ) } +// ============================================================================ +// Gauge Rendering +// ============================================================================ + +const G_SEGMENTS = 19 +const G_START = -225 +const G_SWEEP = 270 +const G_CX = 60 +const G_CY = 60 +const G_R = 44 +const G_GAP = 3 +const G_SW = 8 +const G_NL = 38 + +const gToRad = (deg: number) => (deg * Math.PI) / 180 + +const gSegColor = (i: number): string => { + const t = i / (G_SEGMENTS - 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 renderGauge(value: number, id: string) { + const segSweep = G_SWEEP / G_SEGMENTS + const active = Math.round((value / 100) * G_SEGMENTS) + const innerR = G_R - G_SW / 2 + const outerR = G_R + G_SW / 2 + + const segments = [] + for (let i = 0; i < G_SEGMENTS; i++) { + const s = G_START + i * segSweep + G_GAP / 2 + const e = G_START + (i + 1) * segSweep - G_GAP / 2 + const x1 = G_CX + outerR * Math.cos(gToRad(s)) + const y1 = G_CY + outerR * Math.sin(gToRad(s)) + const x2 = G_CX + outerR * Math.cos(gToRad(e)) + const y2 = G_CY + outerR * Math.sin(gToRad(e)) + const x3 = G_CX + innerR * Math.cos(gToRad(e)) + const y3 = G_CY + innerR * Math.sin(gToRad(e)) + const x4 = G_CX + innerR * Math.cos(gToRad(s)) + const y4 = G_CY + innerR * Math.sin(gToRad(s)) + segments.push( + + ) + } + + const angle = G_START + (value / 100) * G_SWEEP + const nx = G_CX + G_NL * Math.cos(gToRad(angle)) + const ny = G_CY + G_NL * Math.sin(gToRad(angle)) + const pa = angle + 90 + const bw = 3 + const bx1 = G_CX + bw * Math.cos(gToRad(pa)) + const by1 = G_CY + bw * Math.sin(gToRad(pa)) + const bx2 = G_CX - bw * Math.cos(gToRad(pa)) + const by2 = G_CY - bw * Math.sin(gToRad(pa)) + + return ( + + {id} + + {segments} + + + + {value}% + + ) +} + +const gaugeScript = ` +(function() { + var S=19,ST=-225,SW=270,CX=60,CY=60,R=44,W=8,NL=38,GAP=3; + var iR=R-W/2, oR=R+W/2; + function rad(d){return d*Math.PI/180} + function sc(i){var t=i/(S-1);return t<.4?'#4caf50':t<.6?'#8bc34a':t<.75?'#ffc107':t<.9?'#ff9800':'#f44336'} + function upd(id,v){ + var svg=document.getElementById('gauge-'+id);if(!svg)return; + var a=Math.round((v/100)*S); + svg.querySelectorAll('[data-segment]').forEach(function(p,i){p.setAttribute('fill',i { return c.json(history) }) +app.get('/api/system', async c => { + const metrics = await fetchSystemMetrics() + return c.json(metrics) +}) + app.get('/api/history/:name', c => { const name = c.req.param('name') const history = getHistory(name) @@ -956,67 +1106,17 @@ app.get('/', async c => { ) } - // All apps view - const metrics = await getAppMetrics() - - // Sort: running first, then by name - metrics.sort((a, b) => { - if (a.state === 'running' && b.state !== 'running') return -1 - if (a.state !== 'running' && b.state === 'running') return 1 - return a.name.localeCompare(b.name) - }) - - if (metrics.length === 0) { - return c.html( - - No apps found - - ) - } - - const running = metrics.filter(s => s.state === 'running') - const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0) - const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0) - const totalData = metrics.reduce((sum, s) => sum + (s.dataSize ?? 0), 0) + // Dashboard view: system metrics gauges + const sys = await fetchSystemMetrics() return c.html( - - - - - - PID - CPU - MEM - RSS - Data - - - - {metrics.map(s => ( - - - - {s.pid ?? '-'} - {formatPercent(s.cpu)} - {formatPercent(s.memory)} - {formatRss(s.rss)} - {formatBytes(s.dataSize)} - - ))} - -
NameState
- {s.name} - {s.tool && [tool]} - - - {s.state} - -
- - {running.length} running · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} RSS · {formatBytes(totalData)} data - + + {renderGauge(sys.cpu, 'cpu')} + {renderGauge(sys.ram.percent, 'ram')} + {renderGauge(sys.disk.percent, 'disk')} + +