Merge branch 'global-tools'

This commit is contained in:
Chris Wanstrath 2026-03-01 10:16:01 -08:00
commit 64d5295fde
15 changed files with 264 additions and 98 deletions

View File

@ -300,9 +300,35 @@ app.get('/', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
if (!appName) { if (!appName) {
// Dashboard view: global env vars only
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
return c.html( return c.html(
<Layout title="Environment Variables"> <Layout title="Global Environment Variables">
<ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox> {globalVars.length === 0 ? (
<EmptyState>No global environment variables</EmptyState>
) : (
<EnvList>
{globalVars.map(v => (
<EnvItem data-env-item>
<EnvKey>{v.key}</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions>
<Button data-reveal>Reveal</Button>
<form method="post" action={`/delete-global?key=${v.key}`} style="margin:0">
<DangerButton type="submit">Delete</DangerButton>
</form>
</EnvActions>
</EnvItem>
))}
</EnvList>
)}
<Form method="POST" action="/set-global">
<Input type="text" name="key" placeholder="KEY" required />
<Input type="text" name="value" placeholder="value" required />
<Button type="submit">Add</Button>
</Form>
<Hint>Global vars are available to all apps. Changes take effect on next app restart.</Hint>
</Layout> </Layout>
) )
} }
@ -437,7 +463,6 @@ app.post('/delete', async c => {
app.post('/set-global', async c => { app.post('/set-global', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
if (!appName) return c.text('Missing app', 400)
const body = await c.req.parseBody() const body = await c.req.parseBody()
const key = String(body.key).trim().toUpperCase() const key = String(body.key).trim().toUpperCase()
@ -455,17 +480,17 @@ app.post('/set-global', async c => {
} }
writeEnvFile(GLOBAL_ENV_PATH, vars) 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 => { app.post('/delete-global', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
const key = c.req.query('key') 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) const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key)
writeEnvFile(GLOBAL_ENV_PATH, vars) writeEnvFile(GLOBAL_ENV_PATH, vars)
return c.redirect(`/?app=${appName}&tab=global`) return c.redirect(appName ? `/?app=${appName}&tab=global` : '/')
}) })
export default app.defaults export default app.defaults

View File

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

View File

@ -55,6 +55,12 @@ interface ProcessMetrics {
rss: number rss: number
} }
interface SystemMetrics {
cpu: number
ram: { used: number, total: number, percent: number }
disk: { used: number, total: number, percent: number }
}
// ============================================================================ // ============================================================================
// Process Metrics Collection // Process Metrics Collection
// ============================================================================ // ============================================================================
@ -402,6 +408,40 @@ const ChartWrapper = define('ChartWrapper', {
height: '150px', 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',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
padding: '24px',
})
const GaugesGrid = define('GaugesGrid', {
display: 'flex',
justifyContent: 'center',
gap: '40px',
padding: '20px 0',
})
const NoDataMessage = define('NoDataMessage', { const NoDataMessage = define('NoDataMessage', {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -435,6 +475,14 @@ function formatRss(kb?: number): string {
return `${(kb / 1024 / 1024).toFixed(2)} GB` 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 { function getStatusColor(state: string): string {
switch (state) { switch (state) {
case 'running': case 'running':
@ -477,6 +525,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" style="overflow: visible">
{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 // App
// ============================================================================ // ============================================================================
@ -510,6 +659,11 @@ app.get('/api/data-history/:name', c => {
return c.json(history) return c.json(history)
}) })
app.get('/api/system', async c => {
const metrics = await fetchSystemMetrics()
return c.json(metrics)
})
app.get('/api/history/:name', c => { app.get('/api/history/:name', c => {
const name = c.req.param('name') const name = c.req.param('name')
const history = getHistory(name) const history = getHistory(name)
@ -956,67 +1110,17 @@ app.get('/', async c => {
) )
} }
// All apps view // Dashboard view: system metrics gauges
const metrics = await getAppMetrics() const sys = await fetchSystemMetrics()
// 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)
return c.html( return c.html(
<Layout title="Metrics"> <Layout title="Metrics">
<Table> <GaugesGrid>
<thead> {renderGauge(sys.cpu, 'cpu')}
<tr> {renderGauge(sys.ram.percent, 'ram')}
<Th>Name</Th> {renderGauge(sys.disk.percent, 'disk')}
<Th>State</Th> </GaugesGrid>
<ThRight>PID</ThRight> <script dangerouslySetInnerHTML={{ __html: gaugeScript }} />
<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>
</Layout> </Layout>
) )
}) })

View File

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

View File

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

View File

@ -45,8 +45,8 @@ const OpenEmojiPicker = define('OpenEmojiPicker', {
}) })
export function AppDetail({ app, render }: { app: App, render: () => void }) { export function AppDetail({ app, render }: { app: App, render: () => void }) {
// Find all tools // Find tools that show on app pages (apps !== false)
const tools = apps.filter(a => a.tool) const tools = apps.filter(a => a.tool && a.apps !== false)
const selectedTab = getSelectedTab(app.name) const selectedTab = getSelectedTab(app.name)
return ( return (

View File

@ -1,20 +1,22 @@
import { useEffect } from 'hono/jsx' import { useEffect } from 'hono/jsx'
import { navigate } from '../router' import { navigate } from '../router'
import { dashboardTab, isNarrow, setMobileSidebar } from '../state' import { apps, dashboardTab, isNarrow, setMobileSidebar } from '../state'
import { import {
HamburgerButton, HamburgerButton,
HamburgerLine, HamburgerLine,
DashboardContainer, DashboardContainer,
DashboardHeader, DashboardHeader,
DashboardTitle, DashboardTitle,
Section,
SettingsGear, SettingsGear,
Tab, Tab,
TabBar, TabBar,
TabContent, TabContent,
} from '../styles' } from '../styles'
import { theme } from '../themes'
import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs' import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
import { Urls } from './Urls' import { Urls } from './Urls'
import { Vitals, initVitals } from './Vitals' import { initVitals } from './Vitals'
export function DashboardLanding({ render }: { render: () => void }) { export function DashboardLanding({ render }: { render: () => void }) {
useEffect(() => { useEffect(() => {
@ -24,16 +26,19 @@ export function DashboardLanding({ render }: { render: () => void }) {
}, []) }, [])
const narrow = isNarrow || undefined const narrow = isNarrow || undefined
const dashboardTools = apps.filter(a => a.tool && a.dashboard)
const openSettings = () => { const openSettings = () => {
navigate('/settings') navigate('/settings')
} }
const switchTab = (tab: typeof dashboardTab) => { const switchTab = (tab: string) => {
navigate(tab === 'urls' ? '/' : `/${tab}`) navigate(tab === 'urls' ? '/' : `/${tab}`)
if (tab === 'logs') scrollLogsToBottom() if (tab === 'logs') scrollLogsToBottom()
} }
const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1))
return ( return (
<DashboardContainer narrow={narrow} relative> <DashboardContainer narrow={narrow} relative>
<SettingsGear <SettingsGear
@ -63,7 +68,18 @@ export function DashboardLanding({ render }: { render: () => void }) {
<TabBar centered> <TabBar centered>
<Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>URLs</Tab> <Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>URLs</Tab>
<Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>Logs</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> </TabBar>
<TabContent active={dashboardTab === 'urls' || undefined}> <TabContent active={dashboardTab === 'urls' || undefined}>
@ -74,9 +90,26 @@ export function DashboardLanding({ render }: { render: () => void }) {
<UnifiedLogs /> <UnifiedLogs />
</TabContent> </TabContent>
<TabContent active={dashboardTab === 'metrics' || undefined}> {dashboardTools.map(tool => {
<Vitals /> 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> </TabContent>
)
})}
</DashboardContainer> </DashboardContainer>
) )
} }

View File

@ -16,8 +16,8 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`) navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`)
} }
// Find all tools // Find tools that show on app pages (apps !== false)
const tools = apps.filter(a => a.tool) const tools = apps.filter(a => a.tool && a.apps !== false)
const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1)) const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1))
return ( return (

View File

@ -2,7 +2,7 @@ import { render as renderApp } from 'hono/jsx/dom'
import { Dashboard } from './components' import { Dashboard } from './components'
import { initModal } from './components/modal' import { initModal } from './components/modal'
import { initRouter, navigate } from './router' 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 { initToolIframes, updateToolIframes } from './tool-iframes'
import { initUpdate } from './update' import { initUpdate } from './update'
@ -10,8 +10,13 @@ const render = () => {
renderApp(<Dashboard render={render} />, document.getElementById('app')!) renderApp(<Dashboard render={render} />, document.getElementById('app')!)
// Update tool iframes after DOM settles // Update tool iframes after DOM settles
requestAnimationFrame(() => { requestAnimationFrame(() => {
const tools = apps.filter(a => a.tool) if (selectedApp) {
const tools = apps.filter(a => a.tool && a.apps !== false)
updateToolIframes(getSelectedTab(selectedApp), tools, selectedApp) 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') { } else if (path === '/settings') {
setSelectedApp(null) setSelectedApp(null)
setCurrentView('settings') setCurrentView('settings')
} else if (path === '/logs') {
setSelectedApp(null)
setDashboardTab('logs')
setCurrentView('dashboard')
} else if (path === '/metrics') {
setSelectedApp(null)
setDashboardTab('metrics')
setCurrentView('dashboard')
} else { } else {
setSelectedApp(null) setSelectedApp(null)
setDashboardTab('urls') const segment = path.slice(1)
setDashboardTab(segment || 'urls')
setCurrentView('dashboard') setCurrentView('dashboard')
} }

View File

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

View File

@ -27,9 +27,9 @@ router.sse('/stream', (send) => {
let queue = Promise.resolve() let queue = Promise.resolve()
const broadcast = () => { const broadcast = () => {
const apps: SharedApp[] = allApps().map(({ const apps: SharedApp[] = allApps().map(({
name, state, icon, error, port, started, logs, tool, tunnelEnabled, tunnelUrl name, state, icon, error, port, started, logs, tool, apps: apps_, dashboard, tunnelEnabled, tunnelUrl
}) => ({ }) => ({
name, state, icon, error, port, started, logs, tool, tunnelEnabled, tunnelUrl, name, state, icon, error, port, started, logs, tool, apps: apps_, dashboard, tunnelEnabled, tunnelUrl,
})) }))
queue = queue.then(() => send(apps)) queue = queue.then(() => send(apps))
} }

View File

@ -159,7 +159,9 @@ export function registerApp(dir: string) {
const state: AppState = error ? 'invalid' : 'stopped' const state: AppState = error ? 'invalid' : 'stopped'
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
const tool = pkg.toes?.tool const tool = pkg.toes?.tool
_apps.set(dir, { name: dir, state, icon, error, tool }) const apps = pkg.toes?.apps
const dashboard = pkg.toes?.dashboard
_apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard })
update() update()
emit({ type: 'app:create', app: dir }) emit({ type: 'app:create', app: dir })
if (!error) { if (!error) {
@ -379,7 +381,9 @@ function discoverApps() {
const state: AppState = error ? 'invalid' : 'stopped' const state: AppState = error ? 'invalid' : 'stopped'
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
const tool = pkg.toes?.tool const tool = pkg.toes?.tool
_apps.set(dir, { name: dir, state, icon, error, tool }) const apps = pkg.toes?.apps
const dashboard = pkg.toes?.dashboard
_apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard })
} }
update() update()
} }

View File

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

View File

@ -28,6 +28,8 @@ export type App = {
started?: number started?: number
logs?: LogLine[] logs?: LogLine[]
tool?: boolean | string tool?: boolean | string
apps?: boolean
dashboard?: boolean
tunnelEnabled?: boolean tunnelEnabled?: boolean
tunnelUrl?: string tunnelUrl?: string
} }