Add tabbed dashboard with URLs/Logs/Metrics views
This commit is contained in:
parent
488c643342
commit
271bf018b8
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
42
src/client/components/Urls.tsx
Normal file
42
src/client/components/Urls.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ export {
|
||||||
LogStatus,
|
LogStatus,
|
||||||
LogText,
|
LogText,
|
||||||
LogTimestamp,
|
LogTimestamp,
|
||||||
|
UrlLeft,
|
||||||
|
UrlLink,
|
||||||
|
UrlList,
|
||||||
|
UrlPort,
|
||||||
|
UrlRow,
|
||||||
VitalCard,
|
VitalCard,
|
||||||
VitalLabel,
|
VitalLabel,
|
||||||
VitalsSection,
|
VitalsSection,
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user