Base webui and backend additions
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: service webui test build clean
|
.PHONY: service webui dev test build clean
|
||||||
|
|
||||||
export PATH := $(HOME)/.local/go/bin:$(PATH)
|
export PATH := $(HOME)/.local/go/bin:$(PATH)
|
||||||
|
|
||||||
@@ -8,6 +8,13 @@ service:
|
|||||||
webui:
|
webui:
|
||||||
cd webui && npm run dev
|
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:
|
test:
|
||||||
cd service && go test ./...
|
cd service && go test ./...
|
||||||
cd webui && npm test
|
cd webui && npm test
|
||||||
|
|||||||
@@ -14,18 +14,49 @@ A web-based dashboard for managing Proxmox VMs and LXC containers. After configu
|
|||||||
- Go 1.22+
|
- Go 1.22+
|
||||||
- Node.js 20+ and npm
|
- 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
|
```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
|
```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.
|
Optional: set `VITE_API_BASE_URL` (defaults to `http://localhost:8080`) if you call the API without the Vite proxy.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,9 +12,23 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/api"
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/api"
|
||||||
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8080"
|
port = "8080"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.1.0"
|
const Version = "0.0.1"
|
||||||
|
|
||||||
type healthResponse struct {
|
type healthResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "webui",
|
"name": "webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+168
-9
@@ -1,7 +1,7 @@
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: #12263a;
|
color: var(--ctp-text);
|
||||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10,13 +10,16 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 240px;
|
width: 240px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: #f7fafc;
|
background: var(--ctp-mantle);
|
||||||
border-right: 1px solid #c5d4e0;
|
border-right: 1px solid var(--ctp-surface0);
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: 1.25rem 1.25rem 1rem;
|
padding: 1.25rem 1.25rem 1rem;
|
||||||
border-bottom: 1px solid #c5d4e0;
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -25,13 +28,14 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
color: var(--ctp-mauve);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-line {
|
.version-line {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: #3a5166;
|
color: var(--ctp-subtext0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-line + .version-line {
|
.version-line + .version-line {
|
||||||
@@ -49,7 +53,7 @@
|
|||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-top: 1px solid #c5d4e0;
|
border-top: 1px solid var(--ctp-surface0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@@ -67,14 +71,21 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.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 {
|
.nav-item-active {
|
||||||
background: #d7e7f2;
|
background: var(--ctp-mauve-muted);
|
||||||
|
color: var(--ctp-mauve);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +107,160 @@
|
|||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ctp-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content p {
|
.content p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.5;
|
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) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
+162
-19
@@ -1,15 +1,10 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
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 App from './App'
|
||||||
|
import { THEME_STORAGE_KEY } from './theme'
|
||||||
|
|
||||||
describe('App', () => {
|
function stubFetchOk() {
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the dashboard shell with brand and nav', async () => {
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({
|
||||||
@@ -20,13 +15,49 @@ describe('App', () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
stubFetchOk()
|
||||||
|
|
||||||
render(<App />)
|
render(<App />)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', { name: 'Cluster Canvas' }),
|
screen.getByRole('heading', { name: 'Cluster Canvas' }),
|
||||||
).toBeInTheDocument()
|
).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(await screen.findByText('Backend Version 0.1.0')).toBeInTheDocument()
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Overview' })).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 () => {
|
it('switches the content panel when a nav item is selected', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
vi.stubGlobal(
|
stubFetchOk()
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
service: 'clustercanvas',
|
|
||||||
version: '0.1.0',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
render(<App />)
|
render(<App />)
|
||||||
|
|
||||||
@@ -68,6 +90,49 @@ describe('App', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', { name: 'Configuration', level: 2 }),
|
screen.getByRole('heading', { name: 'Configuration', level: 2 }),
|
||||||
).toBeInTheDocument()
|
).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 () => {
|
it('shows unavailable when backend status fetch fails', async () => {
|
||||||
@@ -85,4 +150,82 @@ describe('App', () => {
|
|||||||
await screen.findByText('Backend Version unavailable'),
|
await screen.findByText('Backend Version unavailable'),
|
||||||
).toBeInTheDocument()
|
).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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { fetchStatus } from './api/client'
|
import { fetchStatus } from './api/client'
|
||||||
|
import {
|
||||||
|
applyThemeFromPreference,
|
||||||
|
getSystemPrefersDark,
|
||||||
|
readThemePreference,
|
||||||
|
writeThemePreference,
|
||||||
|
type ThemePreference,
|
||||||
|
} from './theme'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
type SectionId = 'overview' | 'vms' | 'containers' | 'configuration'
|
type SectionId = 'overview' | 'vms' | 'containers' | 'configuration'
|
||||||
@@ -17,6 +24,25 @@ const SECTION_TITLES: Record<SectionId, string> = {
|
|||||||
configuration: 'Configuration',
|
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() {
|
function CogIcon() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<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() {
|
function App() {
|
||||||
const [activeSection, setActiveSection] = useState<SectionId>('overview')
|
const [activeSection, setActiveSection] = useState<SectionId>('overview')
|
||||||
const [backendVersion, setBackendVersion] = useState('…')
|
const [backendVersion, setBackendVersion] = useState('…')
|
||||||
|
const { themePreference, updateThemePreference } = useThemePreference()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isCancelled = false
|
let isCancelled = false
|
||||||
@@ -110,7 +306,14 @@ function App() {
|
|||||||
|
|
||||||
<main className="content" aria-label={SECTION_TITLES[activeSection]}>
|
<main className="content" aria-label={SECTION_TITLES[activeSection]}>
|
||||||
<h2>{SECTION_TITLES[activeSection]}</h2>
|
<h2>{SECTION_TITLES[activeSection]}</h2>
|
||||||
|
{activeSection === 'configuration' ? (
|
||||||
|
<ConfigurationPanel
|
||||||
|
themePreference={themePreference}
|
||||||
|
onThemePreferenceChange={updateThemePreference}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<p>Coming soon</p>
|
<p>Coming soon</p>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+48
-3
@@ -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 {
|
:root {
|
||||||
color: #12263a;
|
color: var(--ctp-text);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, #d7e7f2 0%, transparent 45%),
|
radial-gradient(
|
||||||
linear-gradient(180deg, #f4f8fb 0%, #e8eef3 100%);
|
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-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
transition:
|
||||||
|
color 160ms ease,
|
||||||
|
background-color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -17,6 +61,7 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { applyThemeFromPreference } from './theme.ts'
|
||||||
|
|
||||||
|
applyThemeFromPreference()
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user