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
+8 -1
View File
@@ -1,4 +1,4 @@
.PHONY: service webui test build clean
.PHONY: service webui dev test build clean
export PATH := $(HOME)/.local/go/bin:$(PATH)
@@ -8,6 +8,13 @@ service:
webui:
cd webui && npm run dev
# Run API (:8080) and web UI (:5173) together; Ctrl+C stops both.
dev:
@trap 'kill 0' EXIT INT TERM; \
(cd service && go run ./cmd/server) & \
(cd webui && npm run dev) & \
wait
test:
cd service && go test ./...
cd webui && npm test
+36 -5
View File
@@ -14,18 +14,49 @@ A web-based dashboard for managing Proxmox VMs and LXC containers. After configu
- Go 1.22+
- Node.js 20+ and npm
## Run
## Configuration
Start the API (default `:8080`, override with `PORT`):
Service config lives under **`/etc/ClusterCanvas/`** by default:
| File | Purpose |
|------|---------|
| `settings.json` | Plain base settings (non-secret) |
| `secrets.enc` | AES-256-GCM encrypted JSON secrets |
**Config directory precedence** (highest first):
1. `-configdir` flag — e.g. `./bin/clustercanvas-server -configdir="~/.myconfigs"`
2. `CLUSTERCANVAS_CONFIG_DIR` environment variable
3. `/etc/ClusterCanvas`
A leading `~/` in the path is expanded to the user home directory.
**Secrets encryption key:** set `CLUSTERCANVAS_CONFIG_KEY` to a base64-encoded 32-byte key:
```bash
make service
openssl rand -base64 32
export CLUSTERCANVAS_CONFIG_KEY='…paste output…'
```
Start the web UI (Vite on `:5173`, proxies `/health` and `/api` to the service):
## Run
Start API and web UI together (API `:8080`, UI `:5173`; Ctrl+C stops both):
```bash
make webui
make dev
```
Or start them separately:
```bash
make service # API on :8080 (override with PORT)
make webui # UI on :5173 (proxies /health and /api)
```
Custom config directory for the API:
```bash
cd service && go run ./cmd/server -configdir="$HOME/.myconfigs"
```
Optional: set `VITE_API_BASE_URL` (defaults to `http://localhost:8080`) if you call the API without the Vite proxy.
+15
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"flag"
"log"
"net/http"
"os"
@@ -11,9 +12,23 @@ import (
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/api"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func main() {
configDirFlag := flag.String(
"configdir",
"",
"config directory (default /etc/ClusterCanvas; overrides CLUSTERCANVAS_CONFIG_DIR)",
)
flag.Parse()
configDir, err := settings.ResolveDir(*configDirFlag)
if err != nil {
log.Fatalf("config dir: %v", err)
}
log.Printf("config directory: %s", configDir)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
)
const Version = "0.1.0"
const Version = "0.0.1"
type healthResponse struct {
Status string `json:"status"`
+59
View File
@@ -0,0 +1,59 @@
package settings
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
)
func seal(plaintext []byte, key []byte) (nonce []byte, ciphertext []byte, err error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, fmt.Errorf("aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, fmt.Errorf("gcm: %w", err)
}
nonce = make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, nil, fmt.Errorf("nonce: %w", err)
}
ciphertext = gcm.Seal(nil, nonce, plaintext, nil)
return nonce, ciphertext, nil
}
func open(nonce []byte, ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %w", err)
}
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decrypt: %w", err)
}
return plaintext, nil
}
func decodeKey(base64Key string) ([]byte, error) {
key, err := base64.StdEncoding.DecodeString(base64Key)
if err != nil {
return nil, fmt.Errorf("decode key: %w", err)
}
if length := len(key); length != 16 && length != 24 && length != 32 {
return nil, fmt.Errorf("key must be 16, 24, or 32 bytes after base64 decode, got %d", length)
}
return key, nil
}
+64
View File
@@ -0,0 +1,64 @@
package settings
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const (
DefaultConfigDir = "/etc/ClusterCanvas"
ConfigDirEnvVar = "CLUSTERCANVAS_CONFIG_DIR"
ConfigKeyEnvVar = "CLUSTERCANVAS_CONFIG_KEY"
SettingsFileName = "settings.json"
SecretsFileName = "secrets.enc"
)
// ResolveDir returns the config directory using precedence:
// 1. flagValue (-configdir), if non-empty
// 2. CLUSTERCANVAS_CONFIG_DIR, if set
// 3. /etc/ClusterCanvas
//
// Leading ~/ is expanded to the user home directory.
func ResolveDir(flagValue string) (string, error) {
candidate := strings.TrimSpace(flagValue)
if candidate == "" {
candidate = strings.TrimSpace(os.Getenv(ConfigDirEnvVar))
}
if candidate == "" {
candidate = DefaultConfigDir
}
expanded, err := expandHome(candidate)
if err != nil {
return "", err
}
return filepath.Clean(expanded), nil
}
func expandHome(path string) (string, error) {
if path == "~" {
homeDirectory, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("expand home directory: %w", err)
}
return homeDirectory, nil
}
if strings.HasPrefix(path, "~/") {
homeDirectory, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("expand home directory: %w", err)
}
return filepath.Join(homeDirectory, path[2:]), nil
}
return path, nil
}
func ensureConfigDir(dir string) error {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create config dir %q: %w", dir, err)
}
return nil
}
+90
View File
@@ -0,0 +1,90 @@
package settings
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type encryptedBlob struct {
Nonce string `json:"nonce"`
Ciphertext string `json:"ciphertext"`
}
// KeyFromEnv reads and decodes CLUSTERCANVAS_CONFIG_KEY (base64, 16/24/32 bytes).
func KeyFromEnv() ([]byte, error) {
rawKey := os.Getenv(ConfigKeyEnvVar)
if rawKey == "" {
return nil, fmt.Errorf("%s is not set", ConfigKeyEnvVar)
}
return decodeKey(rawKey)
}
// LoadSecrets decrypts secrets.enc from dir using key.
func LoadSecrets(dir string, key []byte) (Secrets, error) {
path := filepath.Join(dir, SecretsFileName)
payload, err := os.ReadFile(path)
if err != nil {
return Secrets{}, fmt.Errorf("read secrets: %w", err)
}
var blob encryptedBlob
if err := json.Unmarshal(payload, &blob); err != nil {
return Secrets{}, fmt.Errorf("parse secrets envelope: %w", err)
}
nonce, err := base64.StdEncoding.DecodeString(blob.Nonce)
if err != nil {
return Secrets{}, fmt.Errorf("decode nonce: %w", err)
}
ciphertext, err := base64.StdEncoding.DecodeString(blob.Ciphertext)
if err != nil {
return Secrets{}, fmt.Errorf("decode ciphertext: %w", err)
}
plaintext, err := open(nonce, ciphertext, key)
if err != nil {
return Secrets{}, err
}
var secrets Secrets
if err := json.Unmarshal(plaintext, &secrets); err != nil {
return Secrets{}, fmt.Errorf("parse secrets: %w", err)
}
return secrets, nil
}
// SaveSecrets encrypts secrets and writes secrets.enc to dir with mode 0600.
func SaveSecrets(dir string, secrets Secrets, key []byte) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
plaintext, err := json.Marshal(secrets)
if err != nil {
return fmt.Errorf("encode secrets: %w", err)
}
nonce, ciphertext, err := seal(plaintext, key)
if err != nil {
return err
}
blob := encryptedBlob{
Nonce: base64.StdEncoding.EncodeToString(nonce),
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
}
payload, err := json.MarshalIndent(blob, "", " ")
if err != nil {
return fmt.Errorf("encode secrets envelope: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, SecretsFileName)
if err := os.WriteFile(path, payload, 0o600); err != nil {
return fmt.Errorf("write secrets: %w", err)
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package settings
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// LoadSettings reads and parses settings.json from dir.
func LoadSettings(dir string) (Settings, error) {
path := filepath.Join(dir, SettingsFileName)
payload, err := os.ReadFile(path)
if err != nil {
return Settings{}, fmt.Errorf("read settings: %w", err)
}
var settings Settings
if err := json.Unmarshal(payload, &settings); err != nil {
return Settings{}, fmt.Errorf("parse settings: %w", err)
}
return settings, nil
}
// SaveSettings writes settings.json to dir with mode 0640.
func SaveSettings(dir string, settings Settings) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
payload, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("encode settings: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, SettingsFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write settings: %w", err)
}
return nil
}
+152
View File
@@ -0,0 +1,152 @@
package settings
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
)
func TestResolveDirDefault(t *testing.T) {
t.Setenv(ConfigDirEnvVar, "")
resolved, err := ResolveDir("")
if err != nil {
t.Fatalf("ResolveDir: %v", err)
}
if resolved != DefaultConfigDir {
t.Fatalf("got %q, want %q", resolved, DefaultConfigDir)
}
}
func TestResolveDirFlagOverridesEnv(t *testing.T) {
tempDir := t.TempDir()
flagDir := filepath.Join(tempDir, "from-flag")
envDir := filepath.Join(tempDir, "from-env")
t.Setenv(ConfigDirEnvVar, envDir)
resolved, err := ResolveDir(flagDir)
if err != nil {
t.Fatalf("ResolveDir: %v", err)
}
if resolved != flagDir {
t.Fatalf("got %q, want flag dir %q", resolved, flagDir)
}
}
func TestResolveDirEnvOverridesDefault(t *testing.T) {
tempDir := t.TempDir()
envDir := filepath.Join(tempDir, "from-env")
t.Setenv(ConfigDirEnvVar, envDir)
resolved, err := ResolveDir("")
if err != nil {
t.Fatalf("ResolveDir: %v", err)
}
if resolved != envDir {
t.Fatalf("got %q, want env dir %q", resolved, envDir)
}
}
func TestResolveDirExpandsTilde(t *testing.T) {
homeDirectory, err := os.UserHomeDir()
if err != nil {
t.Fatalf("UserHomeDir: %v", err)
}
t.Setenv(ConfigDirEnvVar, "")
resolved, err := ResolveDir("~/myconfigs")
if err != nil {
t.Fatalf("ResolveDir: %v", err)
}
want := filepath.Join(homeDirectory, "myconfigs")
if resolved != want {
t.Fatalf("got %q, want %q", resolved, want)
}
}
func TestSettingsRoundTrip(t *testing.T) {
dir := t.TempDir()
original := Settings{LogLevel: "debug"}
if err := SaveSettings(dir, original); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
loaded, err := LoadSettings(dir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
if loaded.LogLevel != original.LogLevel {
t.Fatalf("got log_level %q, want %q", loaded.LogLevel, original.LogLevel)
}
}
func testKey(t *testing.T) []byte {
t.Helper()
key := make([]byte, 32)
for index := range key {
key[index] = byte(index + 1)
}
return key
}
func TestSecretsRoundTrip(t *testing.T) {
dir := t.TempDir()
key := testKey(t)
original := Secrets{}
if err := SaveSecrets(dir, original, key); err != nil {
t.Fatalf("SaveSecrets: %v", err)
}
loaded, err := LoadSecrets(dir, key)
if err != nil {
t.Fatalf("LoadSecrets: %v", err)
}
_ = loaded
}
func TestSecretsWrongKeyFails(t *testing.T) {
dir := t.TempDir()
goodKey := testKey(t)
if err := SaveSecrets(dir, Secrets{}, goodKey); err != nil {
t.Fatalf("SaveSecrets: %v", err)
}
badKey := make([]byte, 32)
for index := range badKey {
badKey[index] = 0xff
}
if _, err := LoadSecrets(dir, badKey); err == nil {
t.Fatal("expected decrypt failure with wrong key")
}
}
func TestKeyFromEnvMissing(t *testing.T) {
t.Setenv(ConfigKeyEnvVar, "")
if _, err := KeyFromEnv(); err == nil {
t.Fatal("expected error when key env is unset")
}
}
func TestKeyFromEnvValid(t *testing.T) {
key := testKey(t)
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(key))
decoded, err := KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
if len(decoded) != 32 {
t.Fatalf("got key length %d, want 32", len(decoded))
}
}
func TestKeyFromEnvInvalidLength(t *testing.T) {
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString([]byte("short")))
if _, err := KeyFromEnv(); err == nil {
t.Fatal("expected error for invalid key length")
}
}
+11
View File
@@ -0,0 +1,11 @@
package settings
// Settings holds plain (non-secret) base/CLI configuration.
type Settings struct {
LogLevel string `json:"log_level"`
}
// Secrets holds sensitive configuration encrypted at rest.
type Secrets struct {
// Placeholder until Proxmox/API secrets exist.
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webui",
"private": true,
"version": "0.0.0",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
+168 -9
View File
@@ -1,7 +1,7 @@
.app-shell {
display: flex;
min-height: 100vh;
color: #12263a;
color: var(--ctp-text);
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
}
@@ -10,13 +10,16 @@
flex-direction: column;
width: 240px;
flex-shrink: 0;
background: #f7fafc;
border-right: 1px solid #c5d4e0;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
transition:
background-color 160ms ease,
border-color 160ms ease;
}
.sidebar-header {
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid #c5d4e0;
border-bottom: 1px solid var(--ctp-surface0);
}
.brand {
@@ -25,13 +28,14 @@
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.2;
color: var(--ctp-mauve);
}
.version-line {
margin: 0;
font-size: 0.75rem;
line-height: 1.4;
color: #3a5166;
color: var(--ctp-subtext0);
}
.version-line + .version-line {
@@ -49,7 +53,7 @@
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid #c5d4e0;
border-top: 1px solid var(--ctp-surface0);
}
.nav-item {
@@ -67,14 +71,21 @@
font-size: 0.95rem;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease;
}
.nav-item:hover {
background: #e4eef5;
background: var(--ctp-mauve-hover);
}
.nav-item:focus-visible {
outline: 2px solid var(--ctp-mauve);
outline-offset: 2px;
}
.nav-item-active {
background: #d7e7f2;
background: var(--ctp-mauve-muted);
color: var(--ctp-mauve);
font-weight: 600;
}
@@ -96,12 +107,160 @@
margin: 0 0 0.5rem;
font-size: 1.75rem;
letter-spacing: -0.02em;
color: var(--ctp-text);
}
.content p {
margin: 0;
line-height: 1.5;
color: #3a5166;
color: var(--ctp-subtext1);
}
.config-panel {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 40rem;
}
.config-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.15rem 0.25rem;
border-bottom: 1px solid var(--ctp-surface1);
}
.config-tab {
appearance: none;
margin: 0;
padding: 0.55rem 0.85rem;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: var(--ctp-subtext1);
font: inherit;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
}
.config-tab:hover {
color: var(--ctp-text);
}
.config-tab-active {
color: var(--ctp-mauve);
border-bottom-color: var(--ctp-mauve);
}
.config-tab:focus-visible {
outline: 2px solid var(--ctp-mauve);
outline-offset: 2px;
border-radius: 0.2rem;
}
.config-tab-panel {
min-width: 0;
}
.config-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-width: 28rem;
}
.config-field label {
font-size: 0.9rem;
font-weight: 600;
color: var(--ctp-text);
}
.theme-select {
appearance: none;
width: 100%;
max-width: 16rem;
padding: 0.55rem 0.75rem;
border: 1px solid var(--ctp-surface1);
border-radius: 0.35rem;
background: var(--ctp-mantle);
color: var(--ctp-text);
font: inherit;
font-size: 0.95rem;
cursor: pointer;
}
.theme-select:focus-visible {
outline: 2px solid var(--ctp-mauve);
outline-offset: 2px;
}
.config-hint {
margin: 0;
font-size: 0.85rem;
line-height: 1.45;
color: var(--ctp-subtext0);
}
.config-empty {
margin: 0;
max-width: 36rem;
line-height: 1.5;
color: var(--ctp-subtext1);
}
.config-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.6rem;
padding-top: 1rem;
border-top: 1px solid var(--ctp-surface1);
}
.config-action-revert,
.config-action-save {
appearance: none;
margin: 0;
padding: 0.5rem 1rem;
border-radius: 0.35rem;
font: inherit;
font-size: 0.9rem;
font-weight: 550;
cursor: pointer;
}
.config-action-revert {
border: 1px solid var(--ctp-surface1);
background: transparent;
color: var(--ctp-text);
}
.config-action-revert:hover:not(:disabled) {
background: var(--ctp-surface0);
}
.config-action-save {
border: 1px solid var(--ctp-mauve);
background: var(--ctp-mauve);
color: var(--ctp-base);
}
.config-action-save:hover:not(:disabled) {
filter: brightness(1.05);
}
.config-action-revert:disabled,
.config-action-save:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.config-action-revert:focus-visible,
.config-action-save:focus-visible {
outline: 2px solid var(--ctp-mauve);
outline-offset: 2px;
}
@media (max-width: 640px) {
+165 -22
View File
@@ -1,32 +1,63 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App'
import { THEME_STORAGE_KEY } from './theme'
function stubFetchOk() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
service: 'clustercanvas',
version: '0.1.0',
}),
}),
)
}
function stubMatchMedia(prefersDark: boolean) {
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query: string) => ({
matches: query.includes('dark') ? prefersDark : !prefersDark,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
)
}
describe('App', () => {
beforeEach(() => {
localStorage.clear()
delete document.documentElement.dataset.theme
stubMatchMedia(false)
})
afterEach(() => {
localStorage.clear()
delete document.documentElement.dataset.theme
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',
}),
}),
)
stubFetchOk()
render(<App />)
expect(
screen.getByRole('heading', { name: 'Cluster Canvas' }),
).toBeInTheDocument()
expect(screen.getByText(`Frontend Version ${__FRONTEND_VERSION__}`)).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()
@@ -46,16 +77,7 @@ describe('App', () => {
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',
}),
}),
)
stubFetchOk()
render(<App />)
@@ -68,6 +90,49 @@ describe('App', () => {
expect(
screen.getByRole('heading', { name: 'Configuration', level: 2 }),
).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(screen.getByLabelText('Theme')).toBeInTheDocument()
})
it('switches Configuration tabs and shows empty states', async () => {
const user = userEvent.setup()
stubFetchOk()
render(<App />)
await user.click(screen.getByRole('button', { name: 'Configuration' }))
for (const label of [
'Overview',
'Users',
'Groups',
'Security',
'Integrations',
'Network',
'Advanced',
]) {
expect(screen.getByRole('tab', { name: label })).toBeInTheDocument()
}
await user.click(screen.getByRole('tab', { name: 'Security' }))
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(
screen.getByText(/Service SSH keys and related credentials/i),
).toBeInTheDocument()
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Integrations' }))
expect(screen.getByRole('tab', { name: 'Integrations' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(screen.getByText('Coming soon')).toBeInTheDocument()
})
it('shows unavailable when backend status fetch fails', async () => {
@@ -85,4 +150,82 @@ describe('App', () => {
await screen.findByText('Backend Version unavailable'),
).toBeInTheDocument()
})
it('defaults to latte when preference is auto and browser is light', () => {
stubFetchOk()
stubMatchMedia(false)
render(<App />)
expect(document.documentElement.dataset.theme).toBe('latte')
})
it('does not apply theme until Save is pressed', async () => {
const user = userEvent.setup()
stubFetchOk()
stubMatchMedia(false)
render(<App />)
await user.click(screen.getByRole('button', { name: 'Configuration' }))
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Revert' })).toBeDisabled()
await user.selectOptions(screen.getByLabelText('Theme'), 'dark')
expect(document.documentElement.dataset.theme).toBe('latte')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBeNull()
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Revert' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(document.documentElement.dataset.theme).toBe('mocha')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Revert' })).toBeDisabled()
})
it('reverts draft theme changes without applying them', async () => {
const user = userEvent.setup()
stubFetchOk()
stubMatchMedia(true)
localStorage.setItem(THEME_STORAGE_KEY, 'dark')
render(<App />)
expect(document.documentElement.dataset.theme).toBe('mocha')
await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.selectOptions(screen.getByLabelText('Theme'), 'light')
expect(screen.getByLabelText('Theme')).toHaveValue('light')
expect(document.documentElement.dataset.theme).toBe('mocha')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
await user.click(screen.getByRole('button', { name: 'Revert' }))
expect(screen.getByLabelText('Theme')).toHaveValue('dark')
expect(document.documentElement.dataset.theme).toBe('mocha')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Revert' })).toBeDisabled()
})
it('applies latte when the user saves Light in Configuration', async () => {
const user = userEvent.setup()
stubFetchOk()
stubMatchMedia(true)
localStorage.setItem(THEME_STORAGE_KEY, 'dark')
render(<App />)
await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.selectOptions(screen.getByLabelText('Theme'), 'light')
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(document.documentElement.dataset.theme).toBe('latte')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('light')
})
})
+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>
)
+48 -3
View File
@@ -1,13 +1,57 @@
/* Catppuccin Latte (light) — default until JS sets data-theme */
:root,
[data-theme='latte'] {
--ctp-base: #eff1f5;
--ctp-mantle: #e6e9ef;
--ctp-crust: #dce0e8;
--ctp-surface0: #ccd0da;
--ctp-surface1: #bcc0cc;
--ctp-overlay0: #9ca0b0;
--ctp-overlay1: #8c8fa1;
--ctp-text: #4c4f69;
--ctp-subtext0: #6c6f85;
--ctp-subtext1: #5c5f77;
--ctp-mauve: #8839ef;
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base));
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
color-scheme: light;
}
/* Catppuccin Mocha (dark) */
[data-theme='mocha'] {
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
--ctp-surface0: #313244;
--ctp-surface1: #45475a;
--ctp-overlay0: #6c7086;
--ctp-overlay1: #7f849c;
--ctp-text: #cdd6f4;
--ctp-subtext0: #a6adc8;
--ctp-subtext1: #bac2de;
--ctp-mauve: #cba6f7;
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base));
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));
color-scheme: dark;
}
:root {
color: #12263a;
color: var(--ctp-text);
background:
radial-gradient(circle at top left, #d7e7f2 0%, transparent 45%),
linear-gradient(180deg, #f4f8fb 0%, #e8eef3 100%);
radial-gradient(
circle at top left,
color-mix(in srgb, var(--ctp-mauve) 22%, transparent) 0%,
transparent 45%
),
linear-gradient(180deg, var(--ctp-base) 0%, var(--ctp-mantle) 100%);
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition:
color 160ms ease,
background-color 160ms ease;
}
* {
@@ -17,6 +61,7 @@
body {
margin: 0;
min-height: 100vh;
background: transparent;
}
#root {
+3
View File
@@ -2,6 +2,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { applyThemeFromPreference } from './theme.ts'
applyThemeFromPreference()
createRoot(document.getElementById('root')!).render(
<StrictMode>
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
THEME_STORAGE_KEY,
applyResolvedTheme,
applyThemeFromPreference,
isThemePreference,
readThemePreference,
resolveTheme,
writeThemePreference,
} from './theme'
function createMemoryStorage(initial: Record<string, string> = {}) {
const store = new Map(Object.entries(initial))
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null
},
setItem(key: string, value: string) {
store.set(key, value)
},
}
}
describe('isThemePreference', () => {
it('accepts light, dark, and auto', () => {
expect(isThemePreference('light')).toBe(true)
expect(isThemePreference('dark')).toBe(true)
expect(isThemePreference('auto')).toBe(true)
})
it('rejects invalid values', () => {
expect(isThemePreference('mocha')).toBe(false)
expect(isThemePreference('')).toBe(false)
})
})
describe('readThemePreference', () => {
it('defaults to auto when nothing is stored', () => {
expect(readThemePreference(createMemoryStorage())).toBe('auto')
})
it('returns a stored preference', () => {
const storage = createMemoryStorage({ [THEME_STORAGE_KEY]: 'dark' })
expect(readThemePreference(storage)).toBe('dark')
})
it('falls back to auto for invalid stored values', () => {
const storage = createMemoryStorage({ [THEME_STORAGE_KEY]: 'neon' })
expect(readThemePreference(storage)).toBe('auto')
})
})
describe('writeThemePreference', () => {
it('persists the preference', () => {
const storage = createMemoryStorage()
writeThemePreference('light', storage)
expect(storage.getItem(THEME_STORAGE_KEY)).toBe('light')
})
})
describe('resolveTheme', () => {
it('maps light to latte and dark to mocha', () => {
expect(resolveTheme('light', true)).toBe('latte')
expect(resolveTheme('dark', false)).toBe('mocha')
})
it('maps auto from the browser preference', () => {
expect(resolveTheme('auto', true)).toBe('mocha')
expect(resolveTheme('auto', false)).toBe('latte')
})
})
describe('applyResolvedTheme', () => {
afterEach(() => {
delete document.documentElement.dataset.theme
})
it('sets data-theme on the root element', () => {
applyResolvedTheme('mocha')
expect(document.documentElement.dataset.theme).toBe('mocha')
})
})
describe('applyThemeFromPreference', () => {
afterEach(() => {
delete document.documentElement.dataset.theme
vi.restoreAllMocks()
})
it('applies the resolved theme from preference and system preference', () => {
const resolved = applyThemeFromPreference('auto', true)
expect(resolved).toBe('mocha')
expect(document.documentElement.dataset.theme).toBe('mocha')
})
})
+69
View File
@@ -0,0 +1,69 @@
export type ThemePreference = 'light' | 'dark' | 'auto'
export type ResolvedTheme = 'latte' | 'mocha'
export const THEME_STORAGE_KEY = 'clustercanvas.theme'
const VALID_PREFERENCES: ReadonlySet<string> = new Set([
'light',
'dark',
'auto',
])
export function isThemePreference(value: string): value is ThemePreference {
return VALID_PREFERENCES.has(value)
}
export function readThemePreference(
storage: Pick<Storage, 'getItem'> = localStorage,
): ThemePreference {
const storedValue = storage.getItem(THEME_STORAGE_KEY)
if (storedValue !== null && isThemePreference(storedValue)) {
return storedValue
}
return 'auto'
}
export function writeThemePreference(
preference: ThemePreference,
storage: Pick<Storage, 'setItem'> = localStorage,
): void {
storage.setItem(THEME_STORAGE_KEY, preference)
}
export function resolveTheme(
preference: ThemePreference,
prefersDark: boolean,
): ResolvedTheme {
if (preference === 'light') {
return 'latte'
}
if (preference === 'dark') {
return 'mocha'
}
return prefersDark ? 'mocha' : 'latte'
}
export function getSystemPrefersDark(
mediaQueryList: Pick<MediaQueryList, 'matches'> = window.matchMedia(
'(prefers-color-scheme: dark)',
),
): boolean {
return mediaQueryList.matches
}
export function applyResolvedTheme(
resolvedTheme: ResolvedTheme,
rootElement: HTMLElement = document.documentElement,
): void {
rootElement.dataset.theme = resolvedTheme
}
export function applyThemeFromPreference(
preference: ThemePreference = readThemePreference(),
prefersDark: boolean = getSystemPrefersDark(),
rootElement: HTMLElement = document.documentElement,
): ResolvedTheme {
const resolvedTheme = resolveTheme(preference, prefersDark)
applyResolvedTheme(resolvedTheme, rootElement)
return resolvedTheme
}