Add tabbed dashboard with URLs/Logs/Metrics views

This commit is contained in:
Chris Wanstrath 2026-02-25 19:55:19 -08:00
parent 488c643342
commit 271bf018b8
7 changed files with 152 additions and 32 deletions

View File

@ -1,26 +1,25 @@
import { useEffect } from 'hono/jsx'
import { openAppSelectorModal } from '../modals'
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
import { dashboardTab, isNarrow, setCurrentView, setDashboardTab, setSelectedApp } from '../state'
import {
AppSelectorChevron,
DashboardContainer,
DashboardHeader,
DashboardTitle,
SettingsGear,
StatusDot,
StatusDotLink,
StatusDotsRow,
Tab,
TabBar,
TabContent,
} from '../styles'
import { update } from '../update'
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
import { Urls } from './Urls'
import { Vitals, initVitals } from './Vitals'
let activeTooltip: string | null = null
export function DashboardLanding({ render }: { render: () => void }) {
useEffect(() => {
initUnifiedLogs()
initVitals()
if (dashboardTab === 'logs') scrollLogsToBottom()
}, [])
const narrow = isNarrow || undefined
@ -31,6 +30,12 @@ export function DashboardLanding({ render }: { render: () => void }) {
render()
}
const switchTab = (tab: typeof dashboardTab) => {
setDashboardTab(tab)
render()
if (tab === 'logs') scrollLogsToBottom()
}
return (
<DashboardContainer narrow={narrow} relative>
<SettingsGear
@ -51,32 +56,23 @@ export function DashboardLanding({ render }: { render: () => void }) {
</DashboardTitle>
</DashboardHeader>
<StatusDotsRow>
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
<StatusDotLink
key={app.name}
data-tooltip={app.name}
tooltipVisible={activeTooltip === app.name || undefined}
onClick={(e: Event) => {
e.preventDefault()
if (isNarrow && activeTooltip !== app.name) {
activeTooltip = app.name
render()
return
}
activeTooltip = null
setSelectedApp(app.name)
update()
}}
>
<StatusDot state={app.state} data-app={app.name} />
</StatusDotLink>
))}
</StatusDotsRow>
<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>
</TabBar>
<Vitals />
<TabContent active={dashboardTab === 'urls' || undefined}>
<Urls />
</TabContent>
<TabContent active={dashboardTab === 'logs' || undefined}>
<UnifiedLogs />
</TabContent>
<TabContent active={dashboardTab === 'metrics' || undefined}>
<Vitals />
</TabContent>
</DashboardContainer>
)
}

View File

@ -105,6 +105,13 @@ function renderLogs() {
})
}
export function scrollLogsToBottom() {
requestAnimationFrame(() => {
const el = document.getElementById('unified-logs-body')
if (el) el.scrollTop = el.scrollHeight
})
}
export function initUnifiedLogs() {
if (_source) return
_source = new EventSource('/api/system/logs/stream')

View File

@ -0,0 +1,42 @@
import { buildAppUrl } from '../../shared/urls'
import { apps } from '../state'
import {
EmptyState,
StatusDot,
UrlLeft,
UrlLink,
UrlList,
UrlPort,
UrlRow,
} from '../styles'
export function Urls() {
const nonTools = apps.filter(a => !a.tool)
if (nonTools.length === 0) {
return <EmptyState>No apps installed</EmptyState>
}
return (
<UrlList>
{nonTools.map(app => {
const url = buildAppUrl(app.name, location.origin)
const running = app.state === 'running'
return (
<UrlRow key={app.name}>
<UrlLeft>
<StatusDot state={app.state} />
{running ? (
<UrlLink href={url} target="_blank">{url}</UrlLink>
) : (
<span style={{ color: 'var(--colors-textFaint)' }}>{app.name}</span>
)}
</UrlLeft>
{app.port ? <UrlPort>:{app.port}</UrlPort> : null}
</UrlRow>
)
})}
</UrlList>
)
}

View File

@ -1,10 +1,13 @@
import type { App } from '../shared/types'
export type DashboardTab = 'urls' | 'logs' | 'metrics'
// UI state (survives re-renders)
export let currentView: 'dashboard' | 'settings' = 'dashboard'
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
export let selectedApp: string | null = localStorage.getItem('selectedApp')
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
export let dashboardTab: DashboardTab = (localStorage.getItem('dashboardTab') as DashboardTab) || 'urls'
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
// Server state (from SSE)
@ -14,6 +17,11 @@ export let apps: App[] = []
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
// State setters
export function setDashboardTab(tab: DashboardTab) {
dashboardTab = tab
localStorage.setItem('dashboardTab', tab)
}
export function setCurrentView(view: 'dashboard' | 'settings') {
currentView = view
}

View File

@ -202,3 +202,59 @@ export const LogStatus = define('LogStatus', {
warning: { color: '#f59e0b' },
},
})
// URL List
export const UrlLeft = define('UrlLeft', {
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
})
export const UrlLink = define('UrlLink', {
base: 'a',
color: theme('colors-link'),
textDecoration: 'none',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
selectors: {
'&:hover': { textDecoration: 'underline' },
},
})
export const UrlList = define('UrlList', {
width: '100%',
minWidth: 400,
maxWidth: 800,
display: 'flex',
flexDirection: 'column',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
export const UrlPort = define('UrlPort', {
fontFamily: theme('fonts-mono'),
fontSize: 12,
color: theme('colors-textFaint'),
flexShrink: 0,
})
export const UrlRow = define('UrlRow', {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '10px 16px',
fontFamily: theme('fonts-mono'),
fontSize: 13,
selectors: {
'&:hover': {
background: theme('colors-bgHover'),
},
'&:not(:last-child)': {
borderBottom: `1px solid ${theme('colors-border')}`,
},
},
})

View File

@ -15,6 +15,11 @@ export {
LogStatus,
LogText,
LogTimestamp,
UrlLeft,
UrlLink,
UrlList,
UrlPort,
UrlRow,
VitalCard,
VitalLabel,
VitalsSection,

View File

@ -127,6 +127,12 @@ export const TabBar = define('TabBar', {
display: 'flex',
gap: 24,
marginBottom: 20,
variants: {
centered: {
justifyContent: 'center',
marginBottom: 0,
},
},
})
export const Tab = define('Tab', {