Add dashboard support for tool apps with iframe embedding

This commit is contained in:
Chris Wanstrath 2026-03-01 10:10:49 -08:00
parent b99dd16343
commit 82c8fc42da
8 changed files with 213 additions and 83 deletions

View File

@ -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<SystemMetrics> {
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(
<path
key={i}
data-segment={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={i < active ? gSegColor(i) : 'var(--colors-border)'}
/>
)
}
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 (
<GaugesCard>
<GaugeLabel>{id}</GaugeLabel>
<svg id={`gauge-${id}`} viewBox="10 10 100 55" width="140" height="80">
{segments}
<polygon data-needle points={`${nx},${ny} ${bx1},${by1} ${bx2},${by2}`} fill="var(--colors-text)" />
<circle cx={G_CX} cy={G_CY} r="4" fill="var(--colors-textMuted)" />
</svg>
<GaugeValueText id={`value-${id}`}>{value}%</GaugeValueText>
</GaugesCard>
)
}
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<a?sc(i):'var(--colors-border)')});
var ang=ST+(v/100)*SW,nx=CX+NL*Math.cos(rad(ang)),ny=CY+NL*Math.sin(rad(ang));
var pa=ang+90,bw=3;
var bx1=CX+bw*Math.cos(rad(pa)),by1=CY+bw*Math.sin(rad(pa));
var bx2=CX-bw*Math.cos(rad(pa)),by2=CY-bw*Math.sin(rad(pa));
var n=svg.querySelector('[data-needle]');if(n)n.setAttribute('points',nx+','+ny+' '+bx1+','+by1+' '+bx2+','+by2);
var el=document.getElementById('value-'+id);if(el)el.textContent=v+'%';
}
setInterval(function(){
fetch('/api/system').then(function(r){return r.json()}).then(function(m){
upd('cpu',m.cpu);upd('ram',m.ram.percent);upd('disk',m.disk.percent);
}).catch(function(){});
},2000);
})();
`
// ============================================================================
// App
// ============================================================================
@ -510,6 +655,11 @@ app.get('/api/data-history/:name', c => {
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(
<Layout title="Metrics">
<EmptyState>No apps found</EmptyState>
</Layout>
)
}
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(
<Layout title="Metrics">
<Table>
<thead>
<tr>
<Th>Name</Th>
<Th>State</Th>
<ThRight>PID</ThRight>
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr>
</thead>
<tbody>
{metrics.map(s => (
<Tr>
<Td>
{s.name}
{s.tool && <ToolBadge>[tool]</ToolBadge>}
</Td>
<Td>
<StatusBadge style={`color: ${getStatusColor(s.state)}`}>
{s.state}
</StatusBadge>
</Td>
<TdRight>{s.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(s.cpu)}</TdRight>
<TdRight>{formatPercent(s.memory)}</TdRight>
<TdRight>{formatRss(s.rss)}</TdRight>
<TdRight>{formatBytes(s.dataSize)}</TdRight>
</Tr>
))}
</tbody>
</Table>
<Summary>
{running.length} running &middot; {formatPercent(totalCpu)} CPU &middot; {formatRss(totalRss)} RSS &middot; {formatBytes(totalData)} data
</Summary>
<GaugesGrid>
{renderGauge(sys.cpu, 'cpu')}
{renderGauge(sys.ram.percent, 'ram')}
{renderGauge(sys.disk.percent, 'disk')}
</GaugesGrid>
<script dangerouslySetInnerHTML={{ __html: gaugeScript }} />
</Layout>
)
})

View File

@ -10,7 +10,8 @@
},
"toes": {
"tool": true,
"icon": "📊"
"icon": "📊",
"dashboard": true
},
"devDependencies": {
"@types/bun": "latest"

View File

@ -8,7 +8,7 @@
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.3",
"commander": "^14.0.3",
"commander": "14.0.3",
"diff": "^8.0.3",
"kleur": "^4.1.5",
},

View File

@ -1,20 +1,22 @@
import { useEffect } from 'hono/jsx'
import { navigate } from '../router'
import { dashboardTab, isNarrow, setMobileSidebar } from '../state'
import { apps, dashboardTab, isNarrow, setMobileSidebar } from '../state'
import {
HamburgerButton,
HamburgerLine,
DashboardContainer,
DashboardHeader,
DashboardTitle,
Section,
SettingsGear,
Tab,
TabBar,
TabContent,
} from '../styles'
import { theme } from '../themes'
import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
import { Urls } from './Urls'
import { Vitals, initVitals } from './Vitals'
import { initVitals } from './Vitals'
export function DashboardLanding({ render }: { render: () => void }) {
useEffect(() => {
@ -24,16 +26,19 @@ export function DashboardLanding({ render }: { render: () => void }) {
}, [])
const narrow = isNarrow || undefined
const dashboardTools = apps.filter(a => a.tool && a.dashboard)
const openSettings = () => {
navigate('/settings')
}
const switchTab = (tab: typeof dashboardTab) => {
const switchTab = (tab: string) => {
navigate(tab === 'urls' ? '/' : `/${tab}`)
if (tab === 'logs') scrollLogsToBottom()
}
const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1))
return (
<DashboardContainer narrow={narrow} relative>
<SettingsGear
@ -63,7 +68,18 @@ export function DashboardLanding({ render }: { render: () => void }) {
<TabBar centered>
<Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>URLs</Tab>
<Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>Logs</Tab>
<Tab active={dashboardTab === 'metrics' || undefined} onClick={() => switchTab('metrics')}>Metrics</Tab>
{dashboardTools.map(tool => {
const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name
return (
<Tab
key={tool.name}
active={dashboardTab === tool.name || undefined}
onClick={() => switchTab(tool.name)}
>
{tool.icon} {titlecase(toolName)}
</Tab>
)
})}
</TabBar>
<TabContent active={dashboardTab === 'urls' || undefined}>
@ -74,9 +90,26 @@ export function DashboardLanding({ render }: { render: () => void }) {
<UnifiedLogs />
</TabContent>
<TabContent active={dashboardTab === 'metrics' || undefined}>
<Vitals />
</TabContent>
{dashboardTools.map(tool => {
const isSelected = dashboardTab === tool.name
return (
<TabContent key={tool.name} active={isSelected || undefined}>
<Section>
{tool.state !== 'running' && (
<p style={{ color: theme('colors-textFaint') }}>
Tool is {tool.state}
</p>
)}
{tool.state === 'running' && (
<div
data-tool-target={isSelected ? tool.name : undefined}
style={{ width: '100%', height: '600px' }}
/>
)}
</Section>
</TabContent>
)
})}
</DashboardContainer>
)
}

View File

@ -2,7 +2,7 @@ import { render as renderApp } from 'hono/jsx/dom'
import { Dashboard } from './components'
import { initModal } from './components/modal'
import { initRouter, navigate } from './router'
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state'
import { apps, dashboardTab, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state'
import { initToolIframes, updateToolIframes } from './tool-iframes'
import { initUpdate } from './update'
@ -10,8 +10,13 @@ const render = () => {
renderApp(<Dashboard render={render} />, document.getElementById('app')!)
// Update tool iframes after DOM settles
requestAnimationFrame(() => {
const tools = apps.filter(a => a.tool && a.apps !== false)
updateToolIframes(getSelectedTab(selectedApp), tools, selectedApp)
if (selectedApp) {
const tools = apps.filter(a => a.tool && a.apps !== false)
updateToolIframes(getSelectedTab(selectedApp), tools, selectedApp)
} else {
const tools = apps.filter(a => a.tool && a.dashboard)
updateToolIframes(dashboardTab, tools, null)
}
})
}

View File

@ -42,17 +42,10 @@ function route() {
} else if (path === '/settings') {
setSelectedApp(null)
setCurrentView('settings')
} else if (path === '/logs') {
setSelectedApp(null)
setDashboardTab('logs')
setCurrentView('dashboard')
} else if (path === '/metrics') {
setSelectedApp(null)
setDashboardTab('metrics')
setCurrentView('dashboard')
} else {
setSelectedApp(null)
setDashboardTab('urls')
const segment = path.slice(1)
setDashboardTab(segment || 'urls')
setCurrentView('dashboard')
}

View File

@ -1,6 +1,6 @@
import type { App } from '../shared/types'
export type DashboardTab = 'urls' | 'logs' | 'metrics'
export type DashboardTab = string
// UI state (survives re-renders)
export let currentView: 'dashboard' | 'settings' = 'dashboard'

View File

@ -117,9 +117,7 @@ app.get('/dist/:file', async c => {
// SPA routes — serve the shell for all client-side paths
app.get('/app/:name/:tab', c => c.html(<Shell />))
app.get('/app/:name', c => c.html(<Shell />))
app.get('/logs', c => c.html(<Shell />))
app.get('/metrics', c => c.html(<Shell />))
app.get('/settings', c => c.html(<Shell />))
app.get('/:path', c => c.html(<Shell />))
cleanupStalePublishers()
await initApps()