feat: customer config management — CRUD, API retrieval, per-customer auth (v0.2.0)
New "Configurations" section lets operators pre-configure customer settings
in the Hub, then docker-setup.sh can download a ready-made controller.yaml
using just a customer ID and retrieval password.
- Store: customer_configs table with CRUD + per-customer API key lookup
- API: GET /api/v1/config/{id} with X-Retrieval-Password auth
- Auth: per-customer API keys alongside existing global key (backward compatible)
- Web UI: /configs list, create, edit, delete, YAML preview, copy-to-clipboard
- YAML gen: deep-merge controller.yaml.example template with customer overrides
- Template fetcher: background goroutine refreshing template from Gitea repo
- Navigation: Dashboard / Configurations tabs on all pages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`)
|
||||
|
||||
// handleConfigList shows all customer configurations.
|
||||
func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
|
||||
configs, err := s.store.ListCustomerConfigs()
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to list configs: %v", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Configs []store.CustomerConfig
|
||||
ActiveNav string
|
||||
Flash string
|
||||
}{
|
||||
Configs: configs,
|
||||
ActiveNav: "configs",
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "configs.html", data)
|
||||
}
|
||||
|
||||
// handleConfigNewForm shows the form to create a new customer config.
|
||||
func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := struct {
|
||||
IsNew bool
|
||||
Config *store.CustomerConfig
|
||||
Overrides map[string]interface{}
|
||||
ActiveNav string
|
||||
Error string
|
||||
}{
|
||||
IsNew: true,
|
||||
Config: &store.CustomerConfig{},
|
||||
Overrides: make(map[string]interface{}),
|
||||
ActiveNav: "configs",
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "config_form.html", data)
|
||||
}
|
||||
|
||||
// handleConfigCreate processes the form submission to create a new config.
|
||||
func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
if customerID == "" || !validCustomerID.MatchString(customerID) {
|
||||
s.renderConfigForm(w, true, &store.CustomerConfig{
|
||||
CustomerName: r.FormValue("customer_name"),
|
||||
Domain: r.FormValue("domain"),
|
||||
Email: r.FormValue("email"),
|
||||
}, nil, "Invalid Customer ID. Use only letters, numbers, dots, and hyphens.")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
existing, _ := s.store.GetCustomerConfig(customerID)
|
||||
if existing != nil {
|
||||
s.renderConfigForm(w, true, &store.CustomerConfig{
|
||||
CustomerID: customerID,
|
||||
CustomerName: r.FormValue("customer_name"),
|
||||
Domain: r.FormValue("domain"),
|
||||
Email: r.FormValue("email"),
|
||||
}, nil, fmt.Sprintf("Customer ID %q already exists.", customerID))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate credentials
|
||||
retrievalPassword, err := configgen.RandomHex(32)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
apiKey, err := configgen.RandomHex(32)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build config_json from optional form fields
|
||||
configJSON := buildConfigJSON(r)
|
||||
|
||||
cfg := &store.CustomerConfig{
|
||||
CustomerID: customerID,
|
||||
CustomerName: strings.TrimSpace(r.FormValue("customer_name")),
|
||||
Domain: strings.TrimSpace(r.FormValue("domain")),
|
||||
Email: strings.TrimSpace(r.FormValue("email")),
|
||||
RetrievalPassword: retrievalPassword,
|
||||
APIKey: apiKey,
|
||||
ConfigJSON: configJSON,
|
||||
}
|
||||
|
||||
if err := s.store.SaveCustomerConfig(cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Customer config created: %s", customerID)
|
||||
http.Redirect(w, r, "/configs/"+customerID+"?flash=created", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleConfigDetail shows a customer config with credentials and setup commands.
|
||||
func (s *Server) handleConfigDetail(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
cfg, err := s.store.GetCustomerConfig(customerID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to get config %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse config_json for display
|
||||
var overrides map[string]interface{}
|
||||
json.Unmarshal([]byte(cfg.ConfigJSON), &overrides)
|
||||
|
||||
data := struct {
|
||||
Config *store.CustomerConfig
|
||||
Overrides map[string]interface{}
|
||||
ActiveNav string
|
||||
Flash string
|
||||
}{
|
||||
Config: cfg,
|
||||
Overrides: overrides,
|
||||
ActiveNav: "configs",
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "config_detail.html", data)
|
||||
}
|
||||
|
||||
// handleConfigEditForm shows the edit form for a customer config.
|
||||
func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
cfg, err := s.store.GetCustomerConfig(customerID)
|
||||
if err != nil || cfg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var overrides map[string]interface{}
|
||||
json.Unmarshal([]byte(cfg.ConfigJSON), &overrides)
|
||||
|
||||
data := struct {
|
||||
IsNew bool
|
||||
Config *store.CustomerConfig
|
||||
Overrides map[string]interface{}
|
||||
ActiveNav string
|
||||
Error string
|
||||
}{
|
||||
IsNew: false,
|
||||
Config: cfg,
|
||||
Overrides: overrides,
|
||||
ActiveNav: "configs",
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "config_form.html", data)
|
||||
}
|
||||
|
||||
// handleConfigUpdate processes the edit form submission.
|
||||
func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.store.GetCustomerConfig(customerID)
|
||||
if err != nil || cfg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name"))
|
||||
cfg.Domain = strings.TrimSpace(r.FormValue("domain"))
|
||||
cfg.Email = strings.TrimSpace(r.FormValue("email"))
|
||||
cfg.ConfigJSON = buildConfigJSON(r)
|
||||
|
||||
if err := s.store.SaveCustomerConfig(cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to update config for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Customer config updated: %s", customerID)
|
||||
http.Redirect(w, r, "/configs/"+customerID+"?flash=updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleConfigDelete deletes a customer config.
|
||||
func (s *Server) handleConfigDelete(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if err := s.store.DeleteCustomerConfig(customerID); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to delete config %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Customer config deleted: %s", customerID)
|
||||
http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleConfigPreview returns the generated YAML for a customer config.
|
||||
func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
cfg, err := s.store.GetCustomerConfig(customerID)
|
||||
if err != nil || cfg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
templateYAML := defaultControllerTemplate
|
||||
if s.templateFetcher != nil {
|
||||
templateYAML = s.templateFetcher.Template()
|
||||
}
|
||||
|
||||
yamlOutput, err := configgen.Generate(templateYAML, cfg)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to generate preview for %s: %v", customerID, err)
|
||||
http.Error(w, "Generation error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
|
||||
w.Write([]byte(yamlOutput))
|
||||
}
|
||||
|
||||
// handleConfigRegenPassword regenerates the retrieval password.
|
||||
func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
newPassword, err := configgen.RandomHex(32)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.UpdateRetrievalPassword(customerID, newPassword); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to regen password for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Retrieval password regenerated for %s", customerID)
|
||||
http.Redirect(w, r, "/configs/"+customerID+"?flash=password_regenerated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// renderConfigForm is a helper to re-render the form with an error.
|
||||
func (s *Server) renderConfigForm(w http.ResponseWriter, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) {
|
||||
if overrides == nil {
|
||||
overrides = make(map[string]interface{})
|
||||
}
|
||||
data := struct {
|
||||
IsNew bool
|
||||
Config *store.CustomerConfig
|
||||
Overrides map[string]interface{}
|
||||
ActiveNav string
|
||||
Error string
|
||||
}{
|
||||
IsNew: isNew,
|
||||
Config: cfg,
|
||||
Overrides: overrides,
|
||||
ActiveNav: "configs",
|
||||
Error: errMsg,
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "config_form.html", data)
|
||||
}
|
||||
|
||||
// buildConfigJSON builds the config_json from optional form fields.
|
||||
func buildConfigJSON(r *http.Request) string {
|
||||
overrides := make(map[string]interface{})
|
||||
|
||||
// Infrastructure
|
||||
infra := make(map[string]interface{})
|
||||
if v := strings.TrimSpace(r.FormValue("cf_tunnel_token")); v != "" {
|
||||
infra["cf_tunnel_token"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("cf_api_token")); v != "" {
|
||||
infra["cf_api_token"] = v
|
||||
}
|
||||
if len(infra) > 0 {
|
||||
overrides["infrastructure"] = infra
|
||||
}
|
||||
|
||||
// Git
|
||||
git := make(map[string]interface{})
|
||||
if v := strings.TrimSpace(r.FormValue("git_username")); v != "" {
|
||||
git["username"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("git_token")); v != "" {
|
||||
git["token"] = v
|
||||
}
|
||||
if len(git) > 0 {
|
||||
overrides["git"] = git
|
||||
}
|
||||
|
||||
// Monitoring UUIDs
|
||||
uuids := make(map[string]interface{})
|
||||
for _, key := range []string{"heartbeat", "system_health", "db_dump", "backup", "backup_integrity"} {
|
||||
if v := strings.TrimSpace(r.FormValue("uuid_" + key)); v != "" {
|
||||
uuids[key] = v
|
||||
}
|
||||
}
|
||||
if len(uuids) > 0 {
|
||||
if _, ok := overrides["monitoring"]; !ok {
|
||||
overrides["monitoring"] = make(map[string]interface{})
|
||||
}
|
||||
overrides["monitoring"].(map[string]interface{})["ping_uuids"] = uuids
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(overrides)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// getNestedString is a helper to extract a nested string from a map.
|
||||
func getNestedString(m map[string]interface{}, keys ...string) string {
|
||||
var current interface{} = m
|
||||
for _, key := range keys {
|
||||
if cm, ok := current.(map[string]interface{}); ok {
|
||||
current = cm[key]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if s, ok := current.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
# =============================================================================
|
||||
# Felhom Controller Configuration
|
||||
# =============================================================================
|
||||
# Location: /opt/docker/felhom-controller/controller.yaml
|
||||
#
|
||||
# This file contains ONLY infrastructure and customer identity config.
|
||||
# Application-specific configuration (passwords, paths, etc.) is handled
|
||||
# interactively during first deployment via the dashboard UI and stored
|
||||
# per-app in /opt/docker/stacks/<app>/app.yaml
|
||||
#
|
||||
# Environment variable overrides: FELHOM_<SECTION>_<KEY>
|
||||
# (e.g., FELHOM_CUSTOMER_DOMAIN=example.hu)
|
||||
# =============================================================================
|
||||
|
||||
# --- Customer identity ---
|
||||
customer:
|
||||
id: "demo-felhom" # Unique customer identifier
|
||||
name: "Demo Ügyfél" # Display name (shown on dashboard)
|
||||
domain: "demo-felhom.eu" # Base domain for all services
|
||||
email: "" # Customer notification email (optional)
|
||||
telegram_chat_id: "" # Telegram notifications (optional, future)
|
||||
|
||||
# --- Infrastructure secrets ---
|
||||
infrastructure:
|
||||
cf_tunnel_token: "" # Cloudflare Tunnel token
|
||||
cf_api_token: "" # Cloudflare API token (DNS-01 challenge)
|
||||
|
||||
# --- Paths (system-level only) ---
|
||||
paths:
|
||||
stacks_dir: "/opt/docker/stacks" # Where compose files live
|
||||
data_dir: "/opt/docker/felhom-controller/data"
|
||||
system_data_path: "/mnt/sys_drive" # Mount point of user-data partition on system drive (e.g., /mnt/sys_drive)
|
||||
|
||||
# --- System ---
|
||||
system:
|
||||
reserved_memory_mb: 384 # Memory reserved for OS (excluded from app budget)
|
||||
|
||||
# --- Web UI ---
|
||||
web:
|
||||
listen: ":8080"
|
||||
# Bcrypt hash. Empty = first-visit setup prompt.
|
||||
password_hash: ""
|
||||
session_secret: "" # Auto-generated on first start
|
||||
|
||||
# --- Git synchronization ---
|
||||
git:
|
||||
repo_url: "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
|
||||
branch: "main"
|
||||
sync_interval: "15m"
|
||||
username: ""
|
||||
token: ""
|
||||
|
||||
# --- Stack management ---
|
||||
stacks:
|
||||
protected:
|
||||
- "traefik"
|
||||
- "cloudflared"
|
||||
- "felhom-controller"
|
||||
- "filebrowser"
|
||||
update_window: "03:00-05:00"
|
||||
compose_command: ""
|
||||
|
||||
# --- Backup ---
|
||||
# Per-drive backup paths are computed automatically:
|
||||
# <drive>/backups/primary/restic/ — restic repo per drive
|
||||
# <drive>/backups/primary/<app>/db-dumps/ — DB dumps per app
|
||||
# <drive>/backups/secondary/ — cross-drive rsync + restic
|
||||
backup:
|
||||
enabled: true
|
||||
restic_password_file: "/opt/docker/felhom-controller/data/restic-password"
|
||||
db_dump_schedule: "02:30"
|
||||
restic_schedule: "03:00"
|
||||
retention:
|
||||
keep_daily: 7
|
||||
keep_weekly: 4
|
||||
keep_monthly: 6
|
||||
prune_schedule: "weekly"
|
||||
|
||||
# --- Monitoring ---
|
||||
monitoring:
|
||||
enabled: true
|
||||
healthchecks_base: "https://status.felhom.eu"
|
||||
ping_uuids:
|
||||
heartbeat: "" # Every 5 min — controller process alive
|
||||
system_health: "" # Every 5 min — comprehensive system check
|
||||
db_dump: "" # Daily — after database dumps
|
||||
backup: "" # Daily — after restic snapshot
|
||||
backup_integrity: "" # Weekly (Sunday) — restic check
|
||||
system_health_interval: "5m"
|
||||
health_check_schedule: "06:00"
|
||||
thresholds:
|
||||
disk_warn_percent: 80
|
||||
disk_crit_percent: 90
|
||||
backup_max_age_hours: 36
|
||||
cpu_warn_percent: 90
|
||||
memory_warn_percent: 85
|
||||
temperature_warn_celsius: 75
|
||||
|
||||
# --- Central hub (operator dashboard) ---
|
||||
hub:
|
||||
enabled: true # Enable central reporting
|
||||
url: "https://hub.felhom.eu" # Hub API endpoint
|
||||
api_key: "" # Per-customer API key
|
||||
push_interval: "15m" # How often to push reports
|
||||
|
||||
# --- Self-update ---
|
||||
self_update:
|
||||
enabled: true
|
||||
check_interval: "6h"
|
||||
image: "gitea.dooplex.hu/admin/felhom-controller"
|
||||
auto_update: false
|
||||
health_timeout_seconds: 60
|
||||
|
||||
# --- Notifications ---
|
||||
notifications:
|
||||
customer_events:
|
||||
- "disk_warning"
|
||||
- "backup_failed"
|
||||
- "update_available"
|
||||
- "security_update"
|
||||
operator_events:
|
||||
- "disk_critical"
|
||||
- "backup_failed"
|
||||
- "self_update_failed"
|
||||
- "container_unhealthy"
|
||||
|
||||
# --- Logging ---
|
||||
logging:
|
||||
level: "info"
|
||||
file: ""
|
||||
max_size_mb: 10
|
||||
max_files: 3
|
||||
|
||||
# --- Assets ---
|
||||
assets:
|
||||
# App logos, screenshots, and descriptions are baked into the container
|
||||
# image at build time (from the felhom.eu website assets).
|
||||
# Served locally at /static/assets/ — no external dependency.
|
||||
# The source URL is only used during image build, not at runtime.
|
||||
source_url: "https://felhom.eu"
|
||||
@@ -4,3 +4,6 @@ import "embed"
|
||||
|
||||
//go:embed templates/*
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed controller.yaml.default
|
||||
var defaultControllerTemplate string
|
||||
|
||||
@@ -18,13 +18,14 @@ import (
|
||||
|
||||
// Server handles the dashboard web UI.
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
passwordHash string
|
||||
apiKey string // report API key — used for controller callbacks
|
||||
logger *log.Logger
|
||||
templates *template.Template
|
||||
staleThreshold time.Duration
|
||||
versionChecker *VersionChecker
|
||||
store *store.Store
|
||||
passwordHash string
|
||||
apiKey string // report API key — used for controller callbacks
|
||||
logger *log.Logger
|
||||
templates *template.Template
|
||||
staleThreshold time.Duration
|
||||
versionChecker *VersionChecker
|
||||
templateFetcher *TemplateFetcher
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
@@ -58,6 +59,11 @@ func (s *Server) SetVersionChecker(vc *VersionChecker) {
|
||||
s.versionChecker = vc
|
||||
}
|
||||
|
||||
// SetTemplateFetcher sets the template fetcher for config generation (optional).
|
||||
func (s *Server) SetTemplateFetcher(tf *TemplateFetcher) {
|
||||
s.templateFetcher = tf
|
||||
}
|
||||
|
||||
// ServeHTTP routes web requests.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
@@ -80,6 +86,46 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case strings.HasPrefix(path, "/customers/"):
|
||||
customerID := strings.TrimPrefix(path, "/customers/")
|
||||
s.handleCustomerDetail(w, r, customerID)
|
||||
// Config management routes — exact matches first, then prefix matches
|
||||
case path == "/configs":
|
||||
s.handleConfigList(w, r)
|
||||
case path == "/configs/new":
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleConfigCreate(w, r)
|
||||
} else {
|
||||
s.handleConfigNewForm(w, r)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/delete")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleConfigDelete(w, r, customerID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/edit"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/edit")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleConfigUpdate(w, r, customerID)
|
||||
} else {
|
||||
s.handleConfigEditForm(w, r, customerID)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/preview"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/preview")
|
||||
s.handleConfigPreview(w, r, customerID)
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/regen-password"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/regen-password")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleConfigRegenPassword(w, r, customerID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
s.handleConfigDetail(w, r, customerID)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const templateRawURL = "https://gitea.dooplex.hu/admin/deploy-felhom-compose/raw/branch/main/controller/configs/controller.yaml.example"
|
||||
|
||||
// TemplateFetcher periodically fetches controller.yaml.example from the Gitea
|
||||
// repo and caches it for config generation. Falls back to go:embed default.
|
||||
type TemplateFetcher struct {
|
||||
username string
|
||||
token string
|
||||
fetchInterval time.Duration
|
||||
logger *log.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
cachedTemplate string
|
||||
lastFetch time.Time
|
||||
lastError string
|
||||
}
|
||||
|
||||
// NewTemplateFetcher creates a new TemplateFetcher.
|
||||
func NewTemplateFetcher(username, token string, fetchInterval time.Duration, logger *log.Logger) *TemplateFetcher {
|
||||
return &TemplateFetcher{
|
||||
username: username,
|
||||
token: token,
|
||||
fetchInterval: fetchInterval,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the periodic fetch loop. Call in a goroutine.
|
||||
// It fetches immediately on start, then every fetchInterval.
|
||||
func (tf *TemplateFetcher) Run(ctx context.Context) {
|
||||
tf.fetch()
|
||||
ticker := time.NewTicker(tf.fetchInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
tf.fetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tf *TemplateFetcher) fetch() {
|
||||
req, err := http.NewRequest("GET", templateRawURL, nil)
|
||||
if err != nil {
|
||||
tf.mu.Lock()
|
||||
tf.lastError = err.Error()
|
||||
tf.mu.Unlock()
|
||||
tf.logger.Printf("[WARN] Template fetch: failed to create request: %v", err)
|
||||
return
|
||||
}
|
||||
if tf.username != "" && tf.token != "" {
|
||||
req.SetBasicAuth(tf.username, tf.token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
tf.mu.Lock()
|
||||
tf.lastError = err.Error()
|
||||
tf.mu.Unlock()
|
||||
tf.logger.Printf("[WARN] Template fetch: HTTP request failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
tf.mu.Lock()
|
||||
tf.lastError = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
tf.mu.Unlock()
|
||||
tf.logger.Printf("[WARN] Template fetch: unexpected status %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) // 64KB max
|
||||
if err != nil {
|
||||
tf.mu.Lock()
|
||||
tf.lastError = err.Error()
|
||||
tf.mu.Unlock()
|
||||
tf.logger.Printf("[WARN] Template fetch: read error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
tf.mu.Lock()
|
||||
tf.cachedTemplate = string(body)
|
||||
tf.lastFetch = time.Now()
|
||||
tf.lastError = ""
|
||||
tf.mu.Unlock()
|
||||
tf.logger.Printf("[DEBUG] Template fetched (%d bytes)", len(body))
|
||||
}
|
||||
|
||||
// Template returns the cached controller.yaml template.
|
||||
// If the cache is empty (never fetched or all failures), returns the go:embed fallback.
|
||||
func (tf *TemplateFetcher) Template() string {
|
||||
tf.mu.RLock()
|
||||
defer tf.mu.RUnlock()
|
||||
if tf.cachedTemplate != "" {
|
||||
return tf.cachedTemplate
|
||||
}
|
||||
return defaultControllerTemplate
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Felhom Hub — {{.Config.CustomerID}}</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom Hub</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link active">Configurations</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<a href="/configs" class="back-link">← All Configurations</a>
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-success">
|
||||
{{if eq .Flash "created"}}Configuration created successfully.
|
||||
{{else if eq .Flash "updated"}}Configuration updated.
|
||||
{{else if eq .Flash "password_regenerated"}}Retrieval password regenerated.
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">
|
||||
<code>{{.Config.CustomerID}}</code>
|
||||
{{if .Config.CustomerName}}<span class="text-muted" style="font-weight: 400;"> — {{.Config.CustomerName}}</span>{{end}}
|
||||
</h2>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="/configs/{{.Config.CustomerID}}/edit" class="btn btn-outline">Edit</a>
|
||||
<form method="POST" action="/configs/{{.Config.CustomerID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Delete configuration for {{.Config.CustomerID}}? This cannot be undone.')">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Customer Details</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Customer ID</span>
|
||||
<span class="value"><code>{{.Config.CustomerID}}</code></span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Name</span>
|
||||
<span class="value">{{if .Config.CustomerName}}{{.Config.CustomerName}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Domain</span>
|
||||
<span class="value">{{if .Config.Domain}}{{.Config.Domain}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Email</span>
|
||||
<span class="value">{{if .Config.Email}}{{.Config.Email}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Created</span>
|
||||
<span class="value">{{timeAgo .Config.CreatedAt}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Updated</span>
|
||||
<span class="value">{{timeAgo .Config.UpdatedAt}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Credentials</h2>
|
||||
<div class="credential-row">
|
||||
<div>
|
||||
<span class="label">Retrieval Password</span>
|
||||
<div class="credential-box">
|
||||
<code id="retrieval-pw">{{.Config.RetrievalPassword}}</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('retrieval-pw')" title="Copy">⎘</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="/configs/{{.Config.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
|
||||
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
|
||||
<button type="submit" class="btn btn-outline btn-sm">Regenerate</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="credential-row" style="margin-top: 1rem;">
|
||||
<div>
|
||||
<span class="label">API Key</span>
|
||||
<div class="credential-box">
|
||||
<code id="api-key">{{.Config.APIKey}}</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('api-key')" title="Copy">⎘</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="form-hint">Used by the controller for ongoing hub communication (reports, notifications, backups)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Setup Commands</h2>
|
||||
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">Use one of these methods to configure a customer node:</p>
|
||||
|
||||
<h3>Option 1: docker-setup.sh (recommended)</h3>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-setup">sudo ./docker-setup.sh --hub-customer {{.Config.CustomerID}} --hub-password {{.Config.RetrievalPassword}}</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">⎘</button>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 1rem;">Option 2: Direct download</h3>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.Config.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-curl')" title="Copy">⎘</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>YAML Preview</h2>
|
||||
<div id="yaml-preview" class="yaml-preview">
|
||||
<p class="text-muted">Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Felhom Hub — Configuration Management</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyText(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
const text = el.textContent || el.innerText;
|
||||
navigator.clipboard.writeText(text.trim()).then(function() {
|
||||
const btn = el.parentElement.querySelector('.copy-btn');
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = '✓';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// Load YAML preview
|
||||
fetch('/configs/{{.Config.CustomerID}}/preview')
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(yaml) {
|
||||
document.getElementById('yaml-preview').innerHTML = '<pre>' + yaml.replace(/&/g,'&').replace(/</g,'<') + '</pre>';
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('yaml-preview').innerHTML = '<p class="text-muted">Failed to load preview.</p>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Felhom Hub — {{if .IsNew}}New Configuration{{else}}Edit {{.Config.CustomerID}}{{end}}</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom Hub</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link active">Configurations</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<a href="{{if .IsNew}}/configs{{else}}/configs/{{.Config.CustomerID}}{{end}}" class="back-link">← Back</a>
|
||||
<h2>{{if .IsNew}}New Customer Configuration{{else}}Edit: {{.Config.CustomerID}}{{end}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="flash flash-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="{{if .IsNew}}/configs/new{{else}}/configs/{{.Config.CustomerID}}/edit{{end}}" class="config-form">
|
||||
<div class="card">
|
||||
<h2>Customer Identity</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="customer_id">Customer ID *</label>
|
||||
<input type="text" id="customer_id" name="customer_id"
|
||||
value="{{.Config.CustomerID}}"
|
||||
pattern="[a-zA-Z0-9.\-]+"
|
||||
placeholder="e.g. customer-1"
|
||||
{{if not .IsNew}}readonly{{end}}
|
||||
required>
|
||||
{{if .IsNew}}<span class="form-hint">Letters, numbers, dots, hyphens only</span>{{end}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="customer_name">Display Name *</label>
|
||||
<input type="text" id="customer_name" name="customer_name"
|
||||
value="{{.Config.CustomerName}}"
|
||||
placeholder="e.g. Kovács család"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="domain">Domain *</label>
|
||||
<input type="text" id="domain" name="domain"
|
||||
value="{{.Config.Domain}}"
|
||||
placeholder="e.g. kovacs.felhom.eu"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email"
|
||||
value="{{.Config.Email}}"
|
||||
placeholder="e.g. kovacs@example.com">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="card" {{if index .Overrides "infrastructure"}}open{{end}}>
|
||||
<summary><h2 style="display:inline">Infrastructure</h2></summary>
|
||||
<div class="form-grid" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="cf_tunnel_token">Cloudflare Tunnel Token</label>
|
||||
<input type="text" id="cf_tunnel_token" name="cf_tunnel_token"
|
||||
value="{{with .Overrides}}{{with index . "infrastructure"}}{{with index . "cf_tunnel_token"}}{{.}}{{end}}{{end}}{{end}}"
|
||||
placeholder="Optional">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cf_api_token">Cloudflare API Token</label>
|
||||
<input type="text" id="cf_api_token" name="cf_api_token"
|
||||
value="{{with .Overrides}}{{with index . "infrastructure"}}{{with index . "cf_api_token"}}{{.}}{{end}}{{end}}{{end}}"
|
||||
placeholder="For DNS-01 challenge">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card" {{if index .Overrides "git"}}open{{end}}>
|
||||
<summary><h2 style="display:inline">Git Sync</h2></summary>
|
||||
<div class="form-grid" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="git_username">Git Username</label>
|
||||
<input type="text" id="git_username" name="git_username"
|
||||
value="{{with .Overrides}}{{with index . "git"}}{{with index . "username"}}{{.}}{{end}}{{end}}{{end}}"
|
||||
placeholder="For private catalog">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="git_token">Git Token</label>
|
||||
<input type="text" id="git_token" name="git_token"
|
||||
value="{{with .Overrides}}{{with index . "git"}}{{with index . "token"}}{{.}}{{end}}{{end}}{{end}}"
|
||||
placeholder="For private catalog">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card" {{if index .Overrides "monitoring"}}open{{end}}>
|
||||
<summary><h2 style="display:inline">Monitoring UUIDs</h2></summary>
|
||||
<div class="form-grid" style="margin-top: 1rem;">
|
||||
{{$uuids := ""}}
|
||||
{{with .Overrides}}{{with index . "monitoring"}}{{with index . "ping_uuids"}}{{$uuids = .}}{{end}}{{end}}{{end}}
|
||||
<div class="form-group">
|
||||
<label for="uuid_heartbeat">Heartbeat</label>
|
||||
<input type="text" id="uuid_heartbeat" name="uuid_heartbeat"
|
||||
value="{{with $uuids}}{{with index . "heartbeat"}}{{.}}{{end}}{{end}}"
|
||||
placeholder="UUID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uuid_system_health">System Health</label>
|
||||
<input type="text" id="uuid_system_health" name="uuid_system_health"
|
||||
value="{{with $uuids}}{{with index . "system_health"}}{{.}}{{end}}{{end}}"
|
||||
placeholder="UUID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uuid_db_dump">DB Dump</label>
|
||||
<input type="text" id="uuid_db_dump" name="uuid_db_dump"
|
||||
value="{{with $uuids}}{{with index . "db_dump"}}{{.}}{{end}}{{end}}"
|
||||
placeholder="UUID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uuid_backup">Backup</label>
|
||||
<input type="text" id="uuid_backup" name="uuid_backup"
|
||||
value="{{with $uuids}}{{with index . "backup"}}{{.}}{{end}}{{end}}"
|
||||
placeholder="UUID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uuid_backup_integrity">Backup Integrity</label>
|
||||
<input type="text" id="uuid_backup_integrity" name="uuid_backup_integrity"
|
||||
value="{{with $uuids}}{{with index . "backup_integrity"}}{{.}}{{end}}{{end}}"
|
||||
placeholder="UUID">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
|
||||
<button type="submit" class="btn">{{if .IsNew}}Create Configuration{{else}}Save Changes{{end}}</button>
|
||||
<a href="{{if .IsNew}}/configs{{else}}/configs/{{.Config.CustomerID}}{{end}}" class="btn btn-outline">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer>
|
||||
<p>Felhom Hub — Configuration Management</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Felhom Hub — Configurations</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom Hub</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link active">Configurations</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-success">
|
||||
{{if eq .Flash "deleted"}}Configuration deleted.{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Customer Configurations</h2>
|
||||
<a href="/configs/new" class="btn">+ New Configuration</a>
|
||||
</div>
|
||||
|
||||
{{if not .Configs}}
|
||||
<div class="empty-state">
|
||||
<p>No customer configurations yet.</p>
|
||||
<p class="hint">Create a configuration to pre-provision a customer node.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<table class="dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer ID</th>
|
||||
<th>Name</th>
|
||||
<th>Domain</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Configs}}
|
||||
<tr onclick="window.location='/configs/{{.CustomerID}}'">
|
||||
<td><code>{{.CustomerID}}</code></td>
|
||||
<td>{{if .CustomerName}}{{.CustomerName}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td>{{if .Domain}}{{.Domain}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td>{{timeAgo .CreatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
<footer>
|
||||
<p>Felhom Hub — Configuration Management</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,6 +10,10 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<nav class="nav-links" style="margin-bottom: 0.5rem;">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link">Configurations</a>
|
||||
</nav>
|
||||
<a href="/" class="back-link">← Back to Dashboard</a>
|
||||
<h1>
|
||||
<span class="status-dot" style="color: {{statusColor .OverallStatus}}">{{statusIcon .OverallStatus}}</span>
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom Hub</h1>
|
||||
<p class="subtitle">Customer Overview Dashboard</p>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link active">Dashboard</a>
|
||||
<a href="/configs" class="nav-link">Configurations</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{{if not .}}
|
||||
|
||||
@@ -339,6 +339,202 @@ code {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent);
|
||||
color: #0f172a;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover { opacity: 0.85; }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.config-form .form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="email"] {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-group input[readonly] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Credentials */
|
||||
.credential-row {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.credential-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.credential-box code {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Flash messages */
|
||||
.flash {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flash-success {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
color: var(--red);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
|
||||
/* YAML Preview */
|
||||
.yaml-preview {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.yaml-preview pre {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Muted text */
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container { padding: 1rem; }
|
||||
|
||||
Reference in New Issue
Block a user