Add dashboard shell with sidebar nav and version header.
Replace the health-check stub UI with a left menu and content panel so section navigation and frontend/backend versions are visible in the shell.
This commit is contained in:
+107
-32
@@ -1,43 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import { fetchHealth } from './api/client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchStatus } from './api/client'
|
||||
import './App.css'
|
||||
|
||||
type SectionId = 'overview' | 'vms' | 'containers' | 'configuration'
|
||||
|
||||
const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'vms', label: 'VMs' },
|
||||
{ id: 'containers', label: 'Containers' },
|
||||
]
|
||||
|
||||
const SECTION_TITLES: Record<SectionId, string> = {
|
||||
overview: 'Overview',
|
||||
vms: 'VMs',
|
||||
containers: 'Containers',
|
||||
configuration: 'Configuration',
|
||||
}
|
||||
|
||||
function CogIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="cog-icon"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
height="18"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19.14 12.94c.04-.31.06-.63.06-.94s-.02-.63-.06-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96a7.2 7.2 0 0 0-1.62-.94l-.36-2.54A.48.48 0 0 0 13.9 2h-3.8a.48.48 0 0 0-.48.42l-.36 2.54c-.59.24-1.13.55-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.48a.49.49 0 0 0 .12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94L2.86 14.52a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.3.59.22l2.39-.96c.49.39 1.03.7 1.62.94l.36 2.54c.05.24.24.42.48.42h3.8c.24 0 .43-.18.48-.42l.36-2.54c.59-.24 1.13-.55 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.03-1.58ZM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [healthStatus, setHealthStatus] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('overview')
|
||||
const [backendVersion, setBackendVersion] = useState('…')
|
||||
|
||||
async function handleCheckHealth() {
|
||||
setIsLoading(true)
|
||||
setErrorMessage(null)
|
||||
setHealthStatus(null)
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
try {
|
||||
const health = await fetchHealth()
|
||||
setHealthStatus(health.status)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'unknown health check error'
|
||||
setErrorMessage(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
async function loadBackendVersion() {
|
||||
try {
|
||||
const status = await fetchStatus()
|
||||
if (!isCancelled) {
|
||||
setBackendVersion(status.version)
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setBackendVersion('unavailable')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadBackendVersion()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<h1>ClusterCanvas</h1>
|
||||
<p>Dashboard for Proxmox VMs and LXC containers.</p>
|
||||
<button type="button" onClick={handleCheckHealth} disabled={isLoading}>
|
||||
{isLoading ? 'Checking…' : 'Check service health'}
|
||||
</button>
|
||||
{healthStatus ? (
|
||||
<p role="status">Service health: {healthStatus}</p>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<p role="alert">Health check failed: {errorMessage}</p>
|
||||
) : null}
|
||||
</main>
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar" aria-label="Main menu">
|
||||
<div className="sidebar-header">
|
||||
<h1 className="brand">Cluster Canvas</h1>
|
||||
<p className="version-line">Frontend Version {__FRONTEND_VERSION__}</p>
|
||||
<p className="version-line">Backend Version {backendVersion}</p>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Sections">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={
|
||||
activeSection === item.id
|
||||
? 'nav-item nav-item-active'
|
||||
: 'nav-item'
|
||||
}
|
||||
aria-current={activeSection === item.id ? 'page' : undefined}
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
activeSection === 'configuration'
|
||||
? 'nav-item nav-item-active config-item'
|
||||
: 'nav-item config-item'
|
||||
}
|
||||
aria-current={
|
||||
activeSection === 'configuration' ? 'page' : undefined
|
||||
}
|
||||
onClick={() => setActiveSection('configuration')}
|
||||
>
|
||||
<CogIcon />
|
||||
Configuration
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="content" aria-label={SECTION_TITLES[activeSection]}>
|
||||
<h2>{SECTION_TITLES[activeSection]}</h2>
|
||||
<p>Coming soon</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user