From 271bf018b8cbfef0182f3585ff6560ec39156d62 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 25 Feb 2026 19:55:19 -0800 Subject: [PATCH] Add tabbed dashboard with URLs/Logs/Metrics views --- src/client/components/DashboardLanding.tsx | 60 ++++++++++------------ src/client/components/UnifiedLogs.tsx | 7 +++ src/client/components/Urls.tsx | 42 +++++++++++++++ src/client/state.ts | 8 +++ src/client/styles/dashboard.ts | 56 ++++++++++++++++++++ src/client/styles/index.ts | 5 ++ src/client/styles/misc.ts | 6 +++ 7 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 src/client/components/Urls.tsx diff --git a/src/client/components/DashboardLanding.tsx b/src/client/components/DashboardLanding.tsx index 9db113c..3a1b848 100644 --- a/src/client/components/DashboardLanding.tsx +++ b/src/client/components/DashboardLanding.tsx @@ -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 ( void }) { - - {[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => ( - { - e.preventDefault() - if (isNarrow && activeTooltip !== app.name) { - activeTooltip = app.name - render() - return - } - activeTooltip = null - setSelectedApp(app.name) - update() - }} - > - - - ))} - + + switchTab('urls')}>URLs + switchTab('logs')}>Logs + switchTab('metrics')}>Metrics + - + + + - + + + + + + + ) } diff --git a/src/client/components/UnifiedLogs.tsx b/src/client/components/UnifiedLogs.tsx index 569fbd1..cd4a070 100644 --- a/src/client/components/UnifiedLogs.tsx +++ b/src/client/components/UnifiedLogs.tsx @@ -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') diff --git a/src/client/components/Urls.tsx b/src/client/components/Urls.tsx new file mode 100644 index 0000000..60aa5fd --- /dev/null +++ b/src/client/components/Urls.tsx @@ -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 No apps installed + } + + return ( + + {nonTools.map(app => { + const url = buildAppUrl(app.name, location.origin) + const running = app.state === 'running' + + return ( + + + + {running ? ( + {url} + ) : ( + {app.name} + )} + + {app.port ? :{app.port} : null} + + ) + })} + + ) +} diff --git a/src/client/state.ts b/src/client/state.ts index a01c574..f5c97b3 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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 = 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 } diff --git a/src/client/styles/dashboard.ts b/src/client/styles/dashboard.ts index c144429..9e3d9b7 100644 --- a/src/client/styles/dashboard.ts +++ b/src/client/styles/dashboard.ts @@ -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')}`, + }, + }, +}) diff --git a/src/client/styles/index.ts b/src/client/styles/index.ts index 402c6b7..3e33da7 100644 --- a/src/client/styles/index.ts +++ b/src/client/styles/index.ts @@ -15,6 +15,11 @@ export { LogStatus, LogText, LogTimestamp, + UrlLeft, + UrlLink, + UrlList, + UrlPort, + UrlRow, VitalCard, VitalLabel, VitalsSection, diff --git a/src/client/styles/misc.ts b/src/client/styles/misc.ts index 1b85faf..d598041 100644 --- a/src/client/styles/misc.ts +++ b/src/client/styles/misc.ts @@ -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', {