From 324367abd7562284bf0b4b1730cb38bb45e58fc9 Mon Sep 17 00:00:00 2001 From: squid Date: Thu, 16 Jul 2026 21:35:38 +0200 Subject: [PATCH] Base webui and backend additions --- Makefile | 9 +- README.md | 41 ++++- service/cmd/server/main.go | 15 ++ service/internal/api/handlers.go | 2 +- service/internal/settings/crypto.go | 59 ++++++ service/internal/settings/path.go | 64 +++++++ service/internal/settings/secrets.go | 90 +++++++++ service/internal/settings/settings.go | 42 +++++ service/internal/settings/settings_test.go | 152 +++++++++++++++ service/internal/settings/types.go | 11 ++ webui/package.json | 2 +- webui/src/App.css | 177 +++++++++++++++++- webui/src/App.test.tsx | 187 ++++++++++++++++--- webui/src/App.tsx | 205 ++++++++++++++++++++- webui/src/index.css | 51 ++++- webui/src/main.tsx | 3 + webui/src/theme.test.ts | 95 ++++++++++ webui/src/theme.ts | 69 +++++++ 18 files changed, 1231 insertions(+), 43 deletions(-) create mode 100644 service/internal/settings/crypto.go create mode 100644 service/internal/settings/path.go create mode 100644 service/internal/settings/secrets.go create mode 100644 service/internal/settings/settings.go create mode 100644 service/internal/settings/settings_test.go create mode 100644 service/internal/settings/types.go create mode 100644 webui/src/theme.test.ts create mode 100644 webui/src/theme.ts diff --git a/Makefile b/Makefile index 562a8f1..7a790a7 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 05381a4..d712525 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/service/cmd/server/main.go b/service/cmd/server/main.go index 02f9d31..69fe406 100644 --- a/service/cmd/server/main.go +++ b/service/cmd/server/main.go @@ -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" diff --git a/service/internal/api/handlers.go b/service/internal/api/handlers.go index 531b587..366a37f 100644 --- a/service/internal/api/handlers.go +++ b/service/internal/api/handlers.go @@ -5,7 +5,7 @@ import ( "net/http" ) -const Version = "0.1.0" +const Version = "0.0.1" type healthResponse struct { Status string `json:"status"` diff --git a/service/internal/settings/crypto.go b/service/internal/settings/crypto.go new file mode 100644 index 0000000..f313bf9 --- /dev/null +++ b/service/internal/settings/crypto.go @@ -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 +} diff --git a/service/internal/settings/path.go b/service/internal/settings/path.go new file mode 100644 index 0000000..6a6f625 --- /dev/null +++ b/service/internal/settings/path.go @@ -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 +} diff --git a/service/internal/settings/secrets.go b/service/internal/settings/secrets.go new file mode 100644 index 0000000..b630a84 --- /dev/null +++ b/service/internal/settings/secrets.go @@ -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 +} diff --git a/service/internal/settings/settings.go b/service/internal/settings/settings.go new file mode 100644 index 0000000..d52f173 --- /dev/null +++ b/service/internal/settings/settings.go @@ -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 +} diff --git a/service/internal/settings/settings_test.go b/service/internal/settings/settings_test.go new file mode 100644 index 0000000..43ee77b --- /dev/null +++ b/service/internal/settings/settings_test.go @@ -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") + } +} diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go new file mode 100644 index 0000000..6fa3091 --- /dev/null +++ b/service/internal/settings/types.go @@ -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. +} diff --git a/webui/package.json b/webui/package.json index 6fd83db..cd160d3 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "webui", "private": true, - "version": "0.0.0", + "version": "0.0.1", "type": "module", "scripts": { "dev": "vite", diff --git a/webui/src/App.css b/webui/src/App.css index 5481ecf..4b7f6f4 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -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) { diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index 099ee6d..cd74c63 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -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() 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() @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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') + }) }) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 8f84747..7f9a9dd 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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 = { 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 ( (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 ( +
+ + +

+ Auto follows the browser preference. Light and Dark always use + Catppuccin Latte or Mocha. +

+
+ ) +} + +function ConfigTabPanelContent({ + activeConfigTab, + draftThemePreference, + onDraftThemePreferenceChange, +}: { + activeConfigTab: ConfigTabId + draftThemePreference: ThemePreference + onDraftThemePreferenceChange: (preference: ThemePreference) => void +}) { + if (activeConfigTab === 'overview') { + return ( + + ) + } + + if (activeConfigTab === 'security') { + return ( +

+ Service SSH keys and related credentials will live here — add keys with + a description and an expiry time limit or non-expiry. +

+ ) + } + + return

Coming soon

+} + +function ConfigurationPanel({ + themePreference, + onThemePreferenceChange, +}: { + themePreference: ThemePreference + onThemePreferenceChange: (preference: ThemePreference) => void +}) { + const [activeConfigTab, setActiveConfigTab] = + useState('overview') + const [draftThemePreference, setDraftThemePreference] = + useState(themePreference) + + useEffect(() => { + setDraftThemePreference(themePreference) + }, [themePreference]) + + const isDirty = draftThemePreference !== themePreference + + return ( +
+
+ {CONFIG_TABS.map((tab) => { + const isSelected = activeConfigTab === tab.id + return ( + + ) + })} +
+ +
+ +
+ +
+ + +
+
+ ) +} + function App() { const [activeSection, setActiveSection] = useState('overview') const [backendVersion, setBackendVersion] = useState('…') + const { themePreference, updateThemePreference } = useThemePreference() useEffect(() => { let isCancelled = false @@ -110,7 +306,14 @@ function App() {

{SECTION_TITLES[activeSection]}

-

Coming soon

+ {activeSection === 'configuration' ? ( + + ) : ( +

Coming soon

+ )}
) diff --git a/webui/src/index.css b/webui/src/index.css index d64d0e1..29f2bd4 100644 --- a/webui/src/index.css +++ b/webui/src/index.css @@ -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 { diff --git a/webui/src/main.tsx b/webui/src/main.tsx index bef5202..da16678 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -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( diff --git a/webui/src/theme.test.ts b/webui/src/theme.test.ts new file mode 100644 index 0000000..8227c17 --- /dev/null +++ b/webui/src/theme.test.ts @@ -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 = {}) { + 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') + }) +}) diff --git a/webui/src/theme.ts b/webui/src/theme.ts new file mode 100644 index 0000000..7929d5a --- /dev/null +++ b/webui/src/theme.ts @@ -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 = new Set([ + 'light', + 'dark', + 'auto', +]) + +export function isThemePreference(value: string): value is ThemePreference { + return VALID_PREFERENCES.has(value) +} + +export function readThemePreference( + storage: Pick = 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 = 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 = 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 +}