Base webui and backend additions

This commit is contained in:
2026-07-16 21:35:38 +02:00
parent d2b92401f1
commit 324367abd7
18 changed files with 1231 additions and 43 deletions
+204 -1
View File
@@ -1,5 +1,12 @@
import { useEffect, useState } from 'react'
import { fetchStatus } from './api/client'
import {
applyThemeFromPreference,
getSystemPrefersDark,
readThemePreference,
writeThemePreference,
type ThemePreference,
} from './theme'
import './App.css'
type SectionId = 'overview' | 'vms' | 'containers' | 'configuration'
@@ -17,6 +24,25 @@ const SECTION_TITLES: Record<SectionId, string> = {
configuration: 'Configuration',
}
type ConfigTabId =
| 'overview'
| 'users'
| 'groups'
| 'security'
| 'integrations'
| 'network'
| 'advanced'
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'groups', label: 'Groups' },
{ id: 'security', label: 'Security' },
{ id: 'integrations', label: 'Integrations' },
{ id: 'network', label: 'Network' },
{ id: 'advanced', label: 'Advanced' },
]
function CogIcon() {
return (
<svg
@@ -35,9 +61,179 @@ function CogIcon() {
)
}
function useThemePreference() {
const [themePreference, setThemePreference] =
useState<ThemePreference>(readThemePreference)
useEffect(() => {
applyThemeFromPreference(themePreference)
if (themePreference !== 'auto') {
return
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
function handleSystemThemeChange() {
applyThemeFromPreference('auto', getSystemPrefersDark(mediaQuery))
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange)
}
}, [themePreference])
function updateThemePreference(nextPreference: ThemePreference) {
writeThemePreference(nextPreference)
setThemePreference(nextPreference)
}
return { themePreference, updateThemePreference }
}
function OverviewConfigTab({
draftThemePreference,
onDraftThemePreferenceChange,
}: {
draftThemePreference: ThemePreference
onDraftThemePreferenceChange: (preference: ThemePreference) => void
}) {
return (
<div className="config-field">
<label htmlFor="theme-preference">Theme</label>
<select
id="theme-preference"
className="theme-select"
value={draftThemePreference}
onChange={(event) => {
onDraftThemePreferenceChange(event.target.value as ThemePreference)
}}
>
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<p className="config-hint">
Auto follows the browser preference. Light and Dark always use
Catppuccin Latte or Mocha.
</p>
</div>
)
}
function ConfigTabPanelContent({
activeConfigTab,
draftThemePreference,
onDraftThemePreferenceChange,
}: {
activeConfigTab: ConfigTabId
draftThemePreference: ThemePreference
onDraftThemePreferenceChange: (preference: ThemePreference) => void
}) {
if (activeConfigTab === 'overview') {
return (
<OverviewConfigTab
draftThemePreference={draftThemePreference}
onDraftThemePreferenceChange={onDraftThemePreferenceChange}
/>
)
}
if (activeConfigTab === 'security') {
return (
<p className="config-empty">
Service SSH keys and related credentials will live here add keys with
a description and an expiry time limit or non-expiry.
</p>
)
}
return <p className="config-empty">Coming soon</p>
}
function ConfigurationPanel({
themePreference,
onThemePreferenceChange,
}: {
themePreference: ThemePreference
onThemePreferenceChange: (preference: ThemePreference) => void
}) {
const [activeConfigTab, setActiveConfigTab] =
useState<ConfigTabId>('overview')
const [draftThemePreference, setDraftThemePreference] =
useState<ThemePreference>(themePreference)
useEffect(() => {
setDraftThemePreference(themePreference)
}, [themePreference])
const isDirty = draftThemePreference !== themePreference
return (
<div className="config-panel">
<div className="config-tabs" role="tablist" aria-label="Configuration">
{CONFIG_TABS.map((tab) => {
const isSelected = activeConfigTab === tab.id
return (
<button
key={tab.id}
type="button"
role="tab"
id={`config-tab-${tab.id}`}
className={
isSelected ? 'config-tab config-tab-active' : 'config-tab'
}
aria-selected={isSelected}
aria-controls="config-tab-panel"
tabIndex={isSelected ? 0 : -1}
onClick={() => setActiveConfigTab(tab.id)}
>
{tab.label}
</button>
)
})}
</div>
<div
className="config-tab-panel"
role="tabpanel"
id="config-tab-panel"
aria-labelledby={`config-tab-${activeConfigTab}`}
>
<ConfigTabPanelContent
activeConfigTab={activeConfigTab}
draftThemePreference={draftThemePreference}
onDraftThemePreferenceChange={setDraftThemePreference}
/>
</div>
<div className="config-actions">
<button
type="button"
className="config-action-revert"
disabled={!isDirty}
onClick={() => setDraftThemePreference(themePreference)}
>
Revert
</button>
<button
type="button"
className="config-action-save"
disabled={!isDirty}
onClick={() => onThemePreferenceChange(draftThemePreference)}
>
Save
</button>
</div>
</div>
)
}
function App() {
const [activeSection, setActiveSection] = useState<SectionId>('overview')
const [backendVersion, setBackendVersion] = useState('…')
const { themePreference, updateThemePreference } = useThemePreference()
useEffect(() => {
let isCancelled = false
@@ -110,7 +306,14 @@ function App() {
<main className="content" aria-label={SECTION_TITLES[activeSection]}>
<h2>{SECTION_TITLES[activeSection]}</h2>
<p>Coming soon</p>
{activeSection === 'configuration' ? (
<ConfigurationPanel
themePreference={themePreference}
onThemePreferenceChange={updateThemePreference}
/>
) : (
<p>Coming soon</p>
)}
</main>
</div>
)