From d2b92401f19f4d6445c70db806513db331d1e610 Mon Sep 17 00:00:00 2001 From: squid Date: Thu, 16 Jul 2026 20:57:47 +0200 Subject: [PATCH] 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. --- webui/src/App.css | 143 +++++++++++++++++++++++++++-------- webui/src/App.test.tsx | 80 +++++++++++++++++++- webui/src/App.tsx | 139 ++++++++++++++++++++++++++-------- webui/src/api/client.test.ts | 34 ++++++++- webui/src/api/client.ts | 15 ++++ webui/src/vite-env.d.ts | 2 + webui/vite.config.ts | 8 ++ 7 files changed, 351 insertions(+), 70 deletions(-) diff --git a/webui/src/App.css b/webui/src/App.css index a5a5339..5481ecf 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -1,47 +1,124 @@ -.app { - max-width: 40rem; - margin: 4rem auto; - padding: 0 1.5rem; - font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; +.app-shell { + display: flex; + min-height: 100vh; color: #12263a; + font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; } -.app h1 { +.sidebar { + display: flex; + flex-direction: column; + width: 240px; + flex-shrink: 0; + background: #f7fafc; + border-right: 1px solid #c5d4e0; +} + +.sidebar-header { + padding: 1.25rem 1.25rem 1rem; + border-bottom: 1px solid #c5d4e0; +} + +.brand { + margin: 0 0 0.65rem; + font-size: 1.35rem; + font-weight: 650; + letter-spacing: -0.02em; + line-height: 1.2; +} + +.version-line { + margin: 0; + font-size: 0.75rem; + line-height: 1.4; + color: #3a5166; +} + +.version-line + .version-line { + margin-top: 0.15rem; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.25rem; + padding: 0.75rem 0.75rem; + overflow-y: auto; +} + +.sidebar-footer { + padding: 0.75rem; + border-top: 1px solid #c5d4e0; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + appearance: none; + border: none; + border-radius: 0.35rem; + background: transparent; + color: inherit; + padding: 0.6rem 0.75rem; + font: inherit; + font-size: 0.95rem; + text-align: left; + cursor: pointer; +} + +.nav-item:hover { + background: #e4eef5; +} + +.nav-item-active { + background: #d7e7f2; + font-weight: 600; +} + +.config-item { + font-weight: 500; +} + +.cog-icon { + flex-shrink: 0; +} + +.content { + flex: 1; + padding: 2rem 2.5rem; + min-width: 0; +} + +.content h2 { margin: 0 0 0.5rem; - font-size: 2.5rem; - letter-spacing: -0.03em; + font-size: 1.75rem; + letter-spacing: -0.02em; } -.app p { - margin: 0 0 1.5rem; +.content p { + margin: 0; line-height: 1.5; color: #3a5166; } -.app button { - appearance: none; - border: 1px solid #1f4b6e; - background: #1f4b6e; - color: #f7fafc; - padding: 0.65rem 1rem; - font: inherit; - cursor: pointer; -} +@media (max-width: 640px) { + .app-shell { + flex-direction: column; + } -.app button:disabled { - opacity: 0.7; - cursor: wait; -} + .sidebar { + width: 100%; + min-height: auto; + } -.app button:not(:disabled):hover { - background: #16384f; -} + .sidebar-nav { + flex: 0 0 auto; + } -.app [role='status'], -.app [role='alert'] { - margin-top: 1.25rem; -} - -.app [role='alert'] { - color: #8a1c1c; + .content { + padding: 1.5rem; + } } diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index dc98baa..099ee6d 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -1,16 +1,88 @@ import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' import App from './App' describe('App', () => { - it('renders the ClusterCanvas shell', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('renders the dashboard shell with brand and nav', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + service: 'clustercanvas', + version: '0.1.0', + }), + }), + ) + render() expect( - screen.getByRole('heading', { name: 'ClusterCanvas' }), + screen.getByRole('heading', { name: 'Cluster Canvas' }), + ).toBeInTheDocument() + expect(screen.getByText(`Frontend Version ${__FRONTEND_VERSION__}`)).toBeInTheDocument() + expect(await screen.findByText('Backend Version 0.1.0')).toBeInTheDocument() + + expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'VMs' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Containers' }), ).toBeInTheDocument() expect( - screen.getByRole('button', { name: 'Check service health' }), + screen.getByRole('button', { name: 'Configuration' }), + ).toBeInTheDocument() + + expect( + screen.getByRole('heading', { name: 'Overview', level: 2 }), + ).toBeInTheDocument() + expect(screen.getByText('Coming soon')).toBeInTheDocument() + }) + + it('switches the content panel when a nav item is selected', async () => { + const user = userEvent.setup() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + service: 'clustercanvas', + version: '0.1.0', + }), + }), + ) + + render() + + await user.click(screen.getByRole('button', { name: 'VMs' })) + expect( + screen.getByRole('heading', { name: 'VMs', level: 2 }), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Configuration' })) + expect( + screen.getByRole('heading', { name: 'Configuration', level: 2 }), + ).toBeInTheDocument() + }) + + it('shows unavailable when backend status fetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }), + ) + + render() + + expect( + await screen.findByText('Backend Version unavailable'), ).toBeInTheDocument() }) }) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 1613ad1..8f84747 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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 = { + overview: 'Overview', + vms: 'VMs', + containers: 'Containers', + configuration: 'Configuration', +} + +function CogIcon() { + return ( + + ) +} + function App() { - const [healthStatus, setHealthStatus] = useState(null) - const [errorMessage, setErrorMessage] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [activeSection, setActiveSection] = useState('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 ( -
-

ClusterCanvas

-

Dashboard for Proxmox VMs and LXC containers.

- - {healthStatus ? ( -

Service health: {healthStatus}

- ) : null} - {errorMessage ? ( -

Health check failed: {errorMessage}

- ) : null} -
+
+ + +
+

{SECTION_TITLES[activeSection]}

+

Coming soon

+
+
) } diff --git a/webui/src/api/client.test.ts b/webui/src/api/client.test.ts index f75cf42..324d82c 100644 --- a/webui/src/api/client.test.ts +++ b/webui/src/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { fetchHealth, getApiBaseUrl } from './client' +import { fetchHealth, fetchStatus, getApiBaseUrl } from './client' describe('getApiBaseUrl', () => { it('defaults to localhost:8080 when unset', () => { @@ -36,3 +36,35 @@ describe('fetchHealth', () => { ) }) }) + +describe('fetchStatus', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns parsed status payload', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ service: 'clustercanvas', version: '0.1.0' }), + }) + + const status = await fetchStatus(fetchMock) + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/status', + ) + expect(status).toEqual({ service: 'clustercanvas', version: '0.1.0' }) + }) + + it('throws when the response is not ok', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({}), + }) + + await expect(fetchStatus(fetchMock)).rejects.toThrow( + 'status check failed: 503', + ) + }) +}) diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index de23654..8ccc7cb 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -2,6 +2,11 @@ export type HealthResponse = { status: string } +export type StatusResponse = { + service: string + version: string +} + export function getApiBaseUrl(): string { return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080' } @@ -15,3 +20,13 @@ export async function fetchHealth( } return (await response.json()) as HealthResponse } + +export async function fetchStatus( + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/status`) + if (!response.ok) { + throw new Error(`status check failed: ${response.status}`) + } + return (await response.json()) as StatusResponse +} diff --git a/webui/src/vite-env.d.ts b/webui/src/vite-env.d.ts index 17aa348..be07416 100644 --- a/webui/src/vite-env.d.ts +++ b/webui/src/vite-env.d.ts @@ -1,5 +1,7 @@ /// +declare const __FRONTEND_VERSION__: string + interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string } diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 47e09dc..93d8d80 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -1,8 +1,16 @@ +import { readFileSync } from 'node:fs' import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' +const packageJson = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf-8'), +) as { version: string } + export default defineConfig({ plugins: [react()], + define: { + __FRONTEND_VERSION__: JSON.stringify(packageJson.version), + }, server: { proxy: { '/health': 'http://localhost:8080',