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 { useEffect } from 'hono/jsx'
import { openAppSelectorModal } from '../modals' import { openAppSelectorModal } from '../modals'
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state' import { dashboardTab, isNarrow, setCurrentView, setDashboardTab, setSelectedApp } from '../state'
import { import {
AppSelectorChevron, AppSelectorChevron,
DashboardContainer, DashboardContainer,
DashboardHeader, DashboardHeader,
DashboardTitle, DashboardTitle,
SettingsGear, SettingsGear,
StatusDot, Tab,
StatusDotLink, TabBar,
StatusDotsRow, TabContent,
} from '../styles' } from '../styles'
import { update } from '../update' import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs' import { Urls } from './Urls'
import { Vitals, initVitals } from './Vitals' import { Vitals, initVitals } from './Vitals'
let activeTooltip: string | null = null
export function DashboardLanding({ render }: { render: () => void }) { export function DashboardLanding({ render }: { render: () => void }) {
useEffect(() => { useEffect(() => {
initUnifiedLogs() initUnifiedLogs()
initVitals() initVitals()
if (dashboardTab === 'logs') scrollLogsToBottom()
}, []) }, [])
const narrow = isNarrow || undefined const narrow = isNarrow || undefined
@ -31,6 +30,12 @@ export function DashboardLanding({ render }: { render: () => void }) {
render() render()
} }
const switchTab = (tab: typeof dashboardTab) => {
setDashboardTab(tab)
render()
if (tab === 'logs') scrollLogsToBottom()
}
return ( return (
<DashboardContainer narrow={narrow} relative> <DashboardContainer narrow={narrow} relative>
<SettingsGear <SettingsGear
@ -51,32 +56,23 @@ export function DashboardLanding({ render }: { render: () => void }) {
</DashboardTitle> </DashboardTitle>
</DashboardHeader> </DashboardHeader>
<StatusDotsRow> <TabBar centered>
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => ( <Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>URLs</Tab>
<StatusDotLink <Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>Logs</Tab>
key={app.name} <Tab active={dashboardTab === 'metrics' || undefined} onClick={() => switchTab('metrics')}>Metrics</Tab>
data-tooltip={app.name} </TabBar>
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>
<Vitals /> <TabContent active={dashboardTab === 'urls' || undefined}>
<Urls />
</TabContent>
<TabContent active={dashboardTab === 'logs' || undefined}>
<UnifiedLogs /> <UnifiedLogs />
</TabContent>
<TabContent active={dashboardTab === 'metrics' || undefined}>
<Vitals />
</TabContent>
</DashboardContainer> </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() { export function initUnifiedLogs() {
if (_source) return if (_source) return
_source = new EventSource('/api/system/logs/stream') _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' import type { App } from '../shared/types'
export type DashboardTab = 'urls' | 'logs' | 'metrics'
// UI state (survives re-renders) // UI state (survives re-renders)
export let currentView: 'dashboard' | 'settings' = 'dashboard' export let currentView: 'dashboard' | 'settings' = 'dashboard'
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
export let selectedApp: string | null = localStorage.getItem('selectedApp') export let selectedApp: string | null = localStorage.getItem('selectedApp')
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true' 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' export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
// Server state (from SSE) // Server state (from SSE)
@ -14,6 +17,11 @@ export let apps: App[] = []
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}') export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
// State setters // State setters
export function setDashboardTab(tab: DashboardTab) {
dashboardTab = tab
localStorage.setItem('dashboardTab', tab)
}
export function setCurrentView(view: 'dashboard' | 'settings') { export function setCurrentView(view: 'dashboard' | 'settings') {
currentView = view currentView = view
} }

View File

@ -202,3 +202,59 @@ export const LogStatus = define('LogStatus', {
warning: { color: '#f59e0b' }, 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, LogStatus,
LogText, LogText,
LogTimestamp, LogTimestamp,
UrlLeft,
UrlLink,
UrlList,
UrlPort,
UrlRow,
VitalCard, VitalCard,
VitalLabel, VitalLabel,
VitalsSection, VitalsSection,

View File

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