forked from defunkt/toes
Merge branch 'global-tools'
This commit is contained in:
commit
64d5295fde
37
apps/env/20260130-000000/index.tsx
vendored
37
apps/env/20260130-000000/index.tsx
vendored
|
|
@ -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(
|
||||
<Layout title="Environment Variables">
|
||||
<ErrorBox>Please specify an app name with ?app=<name></ErrorBox>
|
||||
<Layout title="Global Environment Variables">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
3
apps/env/20260130-000000/package.json
vendored
3
apps/env/20260130-000000/package.json
vendored
|
|
@ -10,7 +10,8 @@
|
|||
},
|
||||
"toes": {
|
||||
"tool": ".env",
|
||||
"icon": "🔑"
|
||||
"icon": "🔑",
|
||||
"dashboard": true
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
|
|
|
|||
|
|
@ -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,40 @@ 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',
|
||||
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', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
|
@ -435,6 +475,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 +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
|
||||
// ============================================================================
|
||||
|
|
@ -510,6 +659,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 +1110,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 · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} RSS · {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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
},
|
||||
"toes": {
|
||||
"tool": true,
|
||||
"icon": "📊"
|
||||
"icon": "📊",
|
||||
"dashboard": true
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 tools that show on app pages (apps !== false)
|
||||
const tools = apps.filter(a => a.tool && a.apps !== false)
|
||||
const selectedTab = getSelectedTab(app.name)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ router.sse('/stream', (send) => {
|
|||
let queue = Promise.resolve()
|
||||
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, 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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +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
|
||||
_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()
|
||||
emit({ type: 'app:create', app: dir })
|
||||
if (!error) {
|
||||
|
|
@ -379,7 +381,9 @@ 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 apps = pkg.toes?.apps
|
||||
const dashboard = pkg.toes?.dashboard
|
||||
_apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard })
|
||||
}
|
||||
update()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export type App = {
|
|||
started?: number
|
||||
logs?: LogLine[]
|
||||
tool?: boolean | string
|
||||
apps?: boolean
|
||||
dashboard?: boolean
|
||||
tunnelEnabled?: boolean
|
||||
tunnelUrl?: string
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user