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')
|
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=<name></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
|
||||||
|
|
|
||||||
3
apps/env/20260130-000000/package.json
vendored
3
apps/env/20260130-000000/package.json
vendored
|
|
@ -10,7 +10,8 @@
|
||||||
},
|
},
|
||||||
"toes": {
|
"toes": {
|
||||||
"tool": ".env",
|
"tool": ".env",
|
||||||
"icon": "🔑"
|
"icon": "🔑",
|
||||||
|
"dashboard": true
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
|
||||||
|
|
@ -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 · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} RSS · {formatBytes(totalData)} data
|
|
||||||
</Summary>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
},
|
},
|
||||||
"toes": {
|
"toes": {
|
||||||
"tool": true,
|
"tool": true,
|
||||||
"icon": "📊"
|
"icon": "📊",
|
||||||
|
"dashboard": true
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
|
||||||
2
bun.lock
2
bun.lock
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user