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:
+19
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
A lightweight Go service that receives periodic reports from felhom-controller instances, stores them in SQLite, and provides a web dashboard for fleet monitoring. Also serves as the infrastructure backup store for disaster recovery.
|
||||
|
||||
**Current version: v0.1.6**
|
||||
**Current version: v0.2.0**
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +38,7 @@ A lightweight Go service that receives periodic reports from felhom-controller i
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All API endpoints require `Authorization: Bearer <report_api_key>` (except `/healthz`).
|
||||
All API endpoints require `Authorization: Bearer <api_key>` (except `/healthz` and `/api/v1/config/{id}`). Auth accepts both the global `report_api_key` and per-customer API keys (generated when creating customer configs).
|
||||
|
||||
### Report Ingest
|
||||
|
||||
@@ -79,6 +79,14 @@ The infra-backup payload contains everything needed to restore a customer deploy
|
||||
|
||||
Notifications are sent via Resend.com email API.
|
||||
|
||||
### Customer Config Retrieval
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/v1/config/{customer_id}` | Download generated controller.yaml (auth: `X-Retrieval-Password` header) |
|
||||
|
||||
Config retrieval uses a separate per-customer retrieval password (not the API key). The Hub generates a complete `controller.yaml` by deep-merging `controller.yaml.example` (periodically fetched from the Gitea repo) with customer-specific overrides (identity, infrastructure tokens, hub API key, session secret).
|
||||
|
||||
### Health
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -91,6 +99,7 @@ Protected by bcrypt password + session cookie (7-day expiry).
|
||||
|
||||
- **Customer overview table:** status indicators (OK/WARN/DOWN), CPU/memory %, disk usage, container counts, backup age, controller version
|
||||
- **Customer detail page:** system info, storage bars, container table, notification preferences, notification log, 24h history graphs
|
||||
- **Configurations page:** CRUD management for customer configs — pre-configure customer identity, infrastructure secrets, monitoring UUIDs; auto-generates retrieval password + per-customer API key; shows setup commands (`docker-setup.sh` and `curl`); YAML preview
|
||||
- **Auto-refresh:** 60-second cycle
|
||||
- **Status logic:**
|
||||
- Green: report < 30 min old, health = ok
|
||||
@@ -107,6 +116,7 @@ SQLite with WAL mode. Tables:
|
||||
| `infra_backups` | Per-customer infrastructure snapshots for disaster recovery |
|
||||
| `customer_notifications` | Email + enabled event types per customer |
|
||||
| `notification_log` | Send/skip/fail history for notifications |
|
||||
| `customer_configs` | Pre-configured customer settings, retrieval passwords, per-customer API keys |
|
||||
|
||||
Retention: configurable (default 90 days), daily prune at 04:30 Budapest time.
|
||||
|
||||
@@ -131,6 +141,13 @@ retention:
|
||||
alerting:
|
||||
stale_threshold: "30m" # Customer considered stale after this duration
|
||||
|
||||
registry:
|
||||
image: "gitea.dooplex.hu/admin/felhom-controller"
|
||||
username: "" # Gitea registry credentials
|
||||
token: ""
|
||||
check_interval: "30m" # How often to check for new controller versions
|
||||
template_interval: "1h" # How often to refresh controller.yaml.example
|
||||
|
||||
server:
|
||||
listen: ":8080"
|
||||
data_dir: "/data" # SQLite database location
|
||||
|
||||
+27
-6
@@ -47,6 +47,7 @@ type Config struct {
|
||||
Username string `yaml:"username"`
|
||||
Token string `yaml:"token"`
|
||||
CheckInterval string `yaml:"check_interval"`
|
||||
TemplateInterval string `yaml:"template_interval"`
|
||||
} `yaml:"registry"`
|
||||
Server struct {
|
||||
Listen string `yaml:"listen"`
|
||||
@@ -88,9 +89,30 @@ func main() {
|
||||
staleThreshold = 30 * time.Minute
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, logger)
|
||||
// Background context for all goroutines
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Initialize template fetcher for customer config generation
|
||||
var templateFetcher *web.TemplateFetcher
|
||||
if cfg.Registry.Username != "" && cfg.Registry.Token != "" {
|
||||
templateInterval, err := time.ParseDuration(cfg.Registry.TemplateInterval)
|
||||
if err != nil {
|
||||
templateInterval = 1 * time.Hour
|
||||
}
|
||||
templateFetcher = web.NewTemplateFetcher(cfg.Registry.Username, cfg.Registry.Token, templateInterval, logger)
|
||||
go templateFetcher.Run(ctx)
|
||||
logger.Printf("[INFO] Template fetcher started (every %s)", cfg.Registry.TemplateInterval)
|
||||
}
|
||||
|
||||
// Initialize handlers — pass templateFetcher as interface (nil-safe)
|
||||
var templateProvider api.ConfigTemplateProvider
|
||||
if templateFetcher != nil {
|
||||
templateProvider = templateFetcher
|
||||
}
|
||||
apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, templateProvider, logger)
|
||||
webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, staleThreshold, logger)
|
||||
webServer.SetTemplateFetcher(templateFetcher)
|
||||
|
||||
// Build HTTP mux
|
||||
mux := http.NewServeMux()
|
||||
@@ -120,10 +142,6 @@ func main() {
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// Background: daily prune + version checker
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Initialize version checker for controller image registry
|
||||
var versionChecker *web.VersionChecker
|
||||
if cfg.Registry.Username != "" && cfg.Registry.Token != "" {
|
||||
@@ -211,6 +229,9 @@ func loadConfig(path string, logger *log.Logger) *Config {
|
||||
if cfg.Registry.CheckInterval == "" {
|
||||
cfg.Registry.CheckInterval = "6h"
|
||||
}
|
||||
if cfg.Registry.TemplateInterval == "" {
|
||||
cfg.Registry.TemplateInterval = "1h"
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ retention:
|
||||
alerting:
|
||||
stale_threshold: "30m" # Customer considered stale if no report for this duration
|
||||
|
||||
# --- Registry / template fetching ---
|
||||
registry:
|
||||
image: "gitea.dooplex.hu/admin/felhom-controller"
|
||||
username: "" # Gitea username (for version check + template fetch)
|
||||
token: "" # Gitea token
|
||||
check_interval: "6h" # How often to check for new controller versions
|
||||
template_interval: "1h" # How often to fetch controller.yaml.example for config generation
|
||||
|
||||
# --- Server ---
|
||||
server:
|
||||
listen: ":8080"
|
||||
|
||||
@@ -16,7 +16,6 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
+85
-24
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -10,9 +11,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// ConfigTemplateProvider returns the controller.yaml template for config generation.
|
||||
type ConfigTemplateProvider interface {
|
||||
Template() string
|
||||
}
|
||||
|
||||
// Handler handles API endpoints for report ingest and customer queries.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
@@ -21,10 +28,11 @@ type Handler struct {
|
||||
fromEmail string
|
||||
logger *log.Logger
|
||||
httpClient *http.Client
|
||||
templateProvider ConfigTemplateProvider
|
||||
}
|
||||
|
||||
// New creates a new API handler.
|
||||
func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, logger *log.Logger) *Handler {
|
||||
func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templateProvider ConfigTemplateProvider, logger *log.Logger) *Handler {
|
||||
return &Handler{
|
||||
store: store,
|
||||
apiKey: apiKey,
|
||||
@@ -32,9 +40,29 @@ func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, logger *log
|
||||
fromEmail: fromEmail,
|
||||
logger: logger,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
templateProvider: templateProvider,
|
||||
}
|
||||
}
|
||||
|
||||
// checkAuth verifies the Bearer token against the global API key or a per-customer API key.
|
||||
// Returns true if authorized.
|
||||
func (h *Handler) checkAuth(r *http.Request) bool {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
return false
|
||||
}
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
|
||||
// Check global key first
|
||||
if h.apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check per-customer key
|
||||
cfg, err := h.store.GetCustomerConfigByAPIKey(token)
|
||||
return err == nil && cfg != nil
|
||||
}
|
||||
|
||||
// ServeHTTP routes API requests.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
|
||||
@@ -60,20 +88,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
h.handleCustomer(w, r, customerID)
|
||||
}
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
|
||||
customerID := strings.TrimPrefix(path, "/config/")
|
||||
h.handleConfigRetrieve(w, r, customerID)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify bearer token
|
||||
if h.apiKey != "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
|
||||
if !h.checkAuth(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
@@ -203,14 +230,10 @@ func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request,
|
||||
|
||||
// handleNotify processes notification events from customer controllers.
|
||||
func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify bearer token (same auth as /report)
|
||||
if h.apiKey != "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
|
||||
if !h.checkAuth(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
@@ -292,14 +315,10 @@ func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleSavePreferences stores notification preferences pushed from a customer controller.
|
||||
func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
// Same bearer token auth as /report and /notify
|
||||
if h.apiKey != "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
|
||||
if !h.checkAuth(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
@@ -330,13 +349,10 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// handleInfraBackupPush stores an infrastructure snapshot from a controller.
|
||||
func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request) {
|
||||
if h.apiKey != "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
|
||||
if !h.checkAuth(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
@@ -365,13 +381,10 @@ func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// handleInfraBackupGet returns the infrastructure backup for a customer.
|
||||
func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if h.apiKey != "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
|
||||
if !h.checkAuth(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if customerID == "" {
|
||||
http.Error(w, "Missing customer_id", http.StatusBadRequest)
|
||||
@@ -393,6 +406,54 @@ func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, c
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// handleConfigRetrieve returns a generated controller.yaml for a customer.
|
||||
// Auth: X-Retrieval-Password header (not Bearer token).
|
||||
func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if customerID == "" {
|
||||
http.Error(w, "Missing customer_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
password := r.Header.Get("X-Retrieval-Password")
|
||||
if password == "" {
|
||||
http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.store.GetCustomerConfig(customerID)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to get customer config for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
|
||||
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if h.templateProvider == nil {
|
||||
http.Error(w, "Config generation not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Printf("[INFO] Config downloaded for customer %s", customerID)
|
||||
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
|
||||
w.Write([]byte(yamlOutput))
|
||||
}
|
||||
|
||||
// sendResendEmail sends an email via the Resend HTTP API.
|
||||
func (h *Handler) sendResendEmail(to, subject, textBody string) error {
|
||||
payload := map[string]interface{}{
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package configgen
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Generate takes the template YAML and a customer config,
|
||||
// then produces a complete controller.yaml with customer-specific values
|
||||
// merged in. The returned string is valid YAML ready for deployment.
|
||||
func Generate(templateYAML string, cfg *store.CustomerConfig) (string, error) {
|
||||
// Parse template into generic map
|
||||
var base map[string]interface{}
|
||||
if err := yaml.Unmarshal([]byte(templateYAML), &base); err != nil {
|
||||
return "", fmt.Errorf("parsing template YAML: %w", err)
|
||||
}
|
||||
if base == nil {
|
||||
base = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Parse customer config_json overrides
|
||||
var overrides map[string]interface{}
|
||||
if cfg.ConfigJSON != "" && cfg.ConfigJSON != "{}" {
|
||||
if err := yaml.Unmarshal([]byte(cfg.ConfigJSON), &overrides); err != nil {
|
||||
return "", fmt.Errorf("parsing config overrides: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply config_json overrides first (deep merge)
|
||||
if len(overrides) > 0 {
|
||||
base = deepMerge(base, overrides)
|
||||
}
|
||||
|
||||
// Apply programmatic overrides — these always win over config_json
|
||||
setNested(base, []string{"customer", "id"}, cfg.CustomerID)
|
||||
setNested(base, []string{"customer", "name"}, cfg.CustomerName)
|
||||
setNested(base, []string{"customer", "domain"}, cfg.Domain)
|
||||
setNested(base, []string{"customer", "email"}, cfg.Email)
|
||||
|
||||
setNested(base, []string{"hub", "enabled"}, true)
|
||||
setNested(base, []string{"hub", "url"}, "https://hub.felhom.eu")
|
||||
setNested(base, []string{"hub", "api_key"}, cfg.APIKey)
|
||||
|
||||
// Generate session secret
|
||||
sessionSecret, err := RandomHex(32)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generating session secret: %w", err)
|
||||
}
|
||||
setNested(base, []string{"web", "session_secret"}, sessionSecret)
|
||||
|
||||
// Marshal back to YAML
|
||||
out, err := yaml.Marshal(base)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshaling YAML: %w", err)
|
||||
}
|
||||
|
||||
// Add header comment
|
||||
header := fmt.Sprintf(
|
||||
"# Felhom Controller Configuration\n# Generated by Felhom Hub for %q on %s\n# Download URL: https://hub.felhom.eu/api/v1/config/%s\n\n",
|
||||
cfg.CustomerID,
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
cfg.CustomerID,
|
||||
)
|
||||
|
||||
return header + string(out), nil
|
||||
}
|
||||
|
||||
// deepMerge recursively merges overlay into base.
|
||||
// When both base and overlay have a map at the same key, they are merged recursively.
|
||||
// Otherwise, the overlay value wins.
|
||||
func deepMerge(base, overlay map[string]interface{}) map[string]interface{} {
|
||||
result := make(map[string]interface{}, len(base))
|
||||
for k, v := range base {
|
||||
result[k] = v
|
||||
}
|
||||
for k, v := range overlay {
|
||||
if baseMap, ok := result[k].(map[string]interface{}); ok {
|
||||
if overlayMap, ok := v.(map[string]interface{}); ok {
|
||||
result[k] = deepMerge(baseMap, overlayMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// setNested sets a value at a nested path in a map, creating intermediate maps as needed.
|
||||
func setNested(m map[string]interface{}, path []string, value interface{}) {
|
||||
for i, key := range path {
|
||||
if i == len(path)-1 {
|
||||
m[key] = value
|
||||
return
|
||||
}
|
||||
sub, ok := m[key].(map[string]interface{})
|
||||
if !ok {
|
||||
sub = make(map[string]interface{})
|
||||
m[key] = sub
|
||||
}
|
||||
m = sub
|
||||
}
|
||||
}
|
||||
|
||||
// RandomHex generates n random bytes and returns them as a hex string.
|
||||
func RandomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -98,6 +98,18 @@ func (s *Store) migrate() error {
|
||||
backup_json TEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customer_configs (
|
||||
customer_id TEXT PRIMARY KEY,
|
||||
customer_name TEXT NOT NULL DEFAULT '',
|
||||
domain TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
retrieval_password TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -499,6 +511,129 @@ func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// CustomerConfig holds a pre-provisioned customer configuration.
|
||||
type CustomerConfig struct {
|
||||
CustomerID string
|
||||
CustomerName string
|
||||
Domain string
|
||||
Email string
|
||||
RetrievalPassword string
|
||||
APIKey string
|
||||
ConfigJSON string // JSON object with customer-specific override fields
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// SaveCustomerConfig creates or updates a customer configuration.
|
||||
func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO customer_configs (customer_id, customer_name, domain, email,
|
||||
retrieval_password, api_key, config_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(customer_id) DO UPDATE SET
|
||||
customer_name = excluded.customer_name,
|
||||
domain = excluded.domain,
|
||||
email = excluded.email,
|
||||
retrieval_password = excluded.retrieval_password,
|
||||
api_key = excluded.api_key,
|
||||
config_json = excluded.config_json,
|
||||
updated_at = datetime('now')`,
|
||||
cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email,
|
||||
cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCustomerConfig returns a customer configuration by ID, or nil if not found.
|
||||
func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
|
||||
var cfg CustomerConfig
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT customer_id, customer_name, domain, email,
|
||||
retrieval_password, api_key, config_json, created_at, updated_at
|
||||
FROM customer_configs WHERE customer_id = ?`,
|
||||
customerID,
|
||||
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
|
||||
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON,
|
||||
&createdAt, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.CreatedAt = parseSQLiteTime(createdAt)
|
||||
cfg.UpdatedAt = parseSQLiteTime(updatedAt)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// ListCustomerConfigs returns all customer configurations ordered by ID.
|
||||
func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT customer_id, customer_name, domain, email,
|
||||
retrieval_password, api_key, config_json, created_at, updated_at
|
||||
FROM customer_configs ORDER BY customer_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []CustomerConfig
|
||||
for rows.Next() {
|
||||
var cfg CustomerConfig
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
|
||||
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON,
|
||||
&createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.CreatedAt = parseSQLiteTime(createdAt)
|
||||
cfg.UpdatedAt = parseSQLiteTime(updatedAt)
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
return configs, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteCustomerConfig deletes a customer configuration.
|
||||
func (s *Store) DeleteCustomerConfig(customerID string) error {
|
||||
_, err := s.db.Exec("DELETE FROM customer_configs WHERE customer_id = ?", customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCustomerConfigByAPIKey looks up a customer config by its unique API key.
|
||||
// Returns nil if no matching key is found.
|
||||
func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error) {
|
||||
var cfg CustomerConfig
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT customer_id, customer_name, domain, email,
|
||||
retrieval_password, api_key, config_json, created_at, updated_at
|
||||
FROM customer_configs WHERE api_key = ?`,
|
||||
apiKey,
|
||||
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
|
||||
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON,
|
||||
&createdAt, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.CreatedAt = parseSQLiteTime(createdAt)
|
||||
cfg.UpdatedAt = parseSQLiteTime(updatedAt)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// UpdateRetrievalPassword updates the retrieval password for a customer config.
|
||||
func (s *Store) UpdateRetrievalPassword(customerID, newPassword string) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE customer_configs SET retrieval_password = ?, updated_at = datetime('now')
|
||||
WHERE customer_id = ?`,
|
||||
newPassword, customerID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// parseSQLiteTime tries multiple formats that modernc.org/sqlite may return.
|
||||
func parseSQLiteTime(s string) time.Time {
|
||||
formats := []string{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ type Server struct {
|
||||
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