diff --git a/hub/Dockerfile b/hub/Dockerfile new file mode 100644 index 0000000..c3ccfbb --- /dev/null +++ b/hub/Dockerfile @@ -0,0 +1,30 @@ +FROM golang:1.24-alpine AS builder + +ARG VERSION=dev +ARG BUILD_TIME=unknown + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 go build \ + -ldflags "-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" \ + -o /felhom-hub ./cmd/hub/ + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=builder /felhom-hub /usr/local/bin/felhom-hub + +RUN mkdir -p /data /etc/felhom-hub + +ENV TZ=Europe/Budapest + +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/felhom-hub"] +CMD ["-config", "/etc/felhom-hub/hub.yaml"] diff --git a/hub/Makefile b/hub/Makefile new file mode 100644 index 0000000..865ce43 --- /dev/null +++ b/hub/Makefile @@ -0,0 +1,25 @@ +VERSION ?= dev +BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +REGISTRY := gitea.dooplex.hu/admin +IMAGE := $(REGISTRY)/felhom-hub + +.PHONY: build run docker docker-push clean + +build: + CGO_ENABLED=0 go build -ldflags "-s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)" \ + -o bin/felhom-hub ./cmd/hub/ + +run: build + ./bin/felhom-hub -config configs/hub.yaml.example + +docker: + docker build --build-arg VERSION=$(VERSION) --build-arg BUILD_TIME=$(BUILD_TIME) \ + -t $(IMAGE):$(VERSION) . + +docker-push: docker + docker push $(IMAGE):$(VERSION) + docker tag $(IMAGE):$(VERSION) $(IMAGE):latest + docker push $(IMAGE):latest + +clean: + rm -rf bin/ diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go new file mode 100644 index 0000000..b2652ca --- /dev/null +++ b/hub/cmd/hub/main.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/api" + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + "gitea.dooplex.hu/admin/felhom-hub/internal/web" + "gopkg.in/yaml.v3" +) + +var ( + Version = "dev" + BuildTime = "unknown" +) + +// Config is the hub configuration loaded from hub.yaml. +type Config struct { + Auth struct { + PasswordHash string `yaml:"password_hash"` + } `yaml:"auth"` + API struct { + ReportAPIKey string `yaml:"report_api_key"` + } `yaml:"api"` + Retention struct { + MaxDays int `yaml:"max_days"` + PruneSchedule string `yaml:"prune_schedule"` + } `yaml:"retention"` + Alerting struct { + StaleThreshold string `yaml:"stale_threshold"` + } `yaml:"alerting"` + Server struct { + Listen string `yaml:"listen"` + DataDir string `yaml:"data_dir"` + } `yaml:"server"` +} + +func main() { + configPath := flag.String("config", "/etc/felhom-hub/hub.yaml", "Path to configuration file") + showVersion := flag.Bool("version", false, "Show version and exit") + flag.Parse() + + if *showVersion { + fmt.Printf("felhom-hub %s (built %s)\n", Version, BuildTime) + os.Exit(0) + } + + logger := log.New(os.Stdout, "", log.LstdFlags) + logger.Printf("[INFO] felhom-hub %s starting", Version) + + // Load config + cfg := loadConfig(*configPath, logger) + + // Ensure data dir exists + os.MkdirAll(cfg.Server.DataDir, 0755) + + // Initialize store + dbPath := filepath.Join(cfg.Server.DataDir, "hub.db") + dataStore, err := store.New(dbPath, logger) + if err != nil { + logger.Fatalf("[FATAL] Failed to initialize store: %v", err) + } + defer dataStore.Close() + logger.Printf("[INFO] Database opened at %s", dbPath) + + // Parse stale threshold + staleThreshold, err := time.ParseDuration(cfg.Alerting.StaleThreshold) + if err != nil { + staleThreshold = 30 * time.Minute + } + + // Initialize handlers + apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, logger) + webServer := web.New(dataStore, cfg.Auth.PasswordHash, staleThreshold, logger) + + // Build HTTP mux + mux := http.NewServeMux() + + // API routes (no dashboard auth for report ingest) + mux.Handle("/api/v1/", apiHandler) + + // Web routes (auth required) + if cfg.Auth.PasswordHash != "" { + mux.Handle("/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHTTP))) + } else { + mux.Handle("/", http.HandlerFunc(webServer.ServeHTTP)) + } + + // Start HTTP server + server := &http.Server{ + Addr: cfg.Server.Listen, + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // Background: daily prune + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if cfg.Retention.MaxDays > 0 { + go pruneLoop(ctx, dataStore, cfg.Retention.MaxDays, logger) + } + + // Signal handling + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigCh + logger.Printf("[INFO] Received signal %v, shutting down...", sig) + cancel() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Printf("[ERROR] HTTP server shutdown error: %v", err) + } + }() + + logger.Printf("[INFO] Listening on %s", cfg.Server.Listen) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + logger.Fatalf("[FATAL] HTTP server error: %v", err) + } + + logger.Println("[INFO] felhom-hub stopped") +} + +func loadConfig(path string, logger *log.Logger) *Config { + cfg := &Config{} + + // Defaults + cfg.Server.Listen = ":8080" + cfg.Server.DataDir = "/data" + cfg.Retention.MaxDays = 90 + cfg.Retention.PruneSchedule = "04:30" + cfg.Alerting.StaleThreshold = "30m" + + data, err := os.ReadFile(path) + if err != nil { + logger.Printf("[WARN] Config file not found at %s, using defaults", path) + return cfg + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + logger.Printf("[WARN] Failed to parse config: %v, using defaults", err) + return cfg + } + + // Apply defaults for zero values + if cfg.Server.Listen == "" { + cfg.Server.Listen = ":8080" + } + if cfg.Server.DataDir == "" { + cfg.Server.DataDir = "/data" + } + if cfg.Retention.MaxDays == 0 { + cfg.Retention.MaxDays = 90 + } + if cfg.Alerting.StaleThreshold == "" { + cfg.Alerting.StaleThreshold = "30m" + } + + return cfg +} + +func pruneLoop(ctx context.Context, s *store.Store, maxDays int, logger *log.Logger) { + // Prune once on startup + if deleted, err := s.Prune(maxDays); err != nil { + logger.Printf("[WARN] Prune failed: %v", err) + } else if deleted > 0 { + logger.Printf("[INFO] Pruned %d old report rows", deleted) + } + + // Then daily + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if deleted, err := s.Prune(maxDays); err != nil { + logger.Printf("[WARN] Prune failed: %v", err) + } else if deleted > 0 { + logger.Printf("[INFO] Pruned %d old report rows", deleted) + } + } + } +} diff --git a/hub/configs/hub.yaml.example b/hub/configs/hub.yaml.example new file mode 100644 index 0000000..c9e8a8b --- /dev/null +++ b/hub/configs/hub.yaml.example @@ -0,0 +1,32 @@ +# ============================================================================= +# Felhom Hub Configuration +# ============================================================================= +# Location: /etc/felhom-hub/hub.yaml (or mount as ConfigMap in k8s) +# +# The hub receives health reports from customer controllers and serves +# a multi-customer overview dashboard for the operator. +# ============================================================================= + +# --- Authentication --- +auth: + # Bcrypt hash for dashboard login (Viktor only) + password_hash: "" + +# --- API --- +api: + # Bearer token required for report ingest (POST /api/v1/report) + report_api_key: "" + +# --- Data retention --- +retention: + max_days: 90 # Keep 90 days of report history + prune_schedule: "04:30" # Daily prune time (Europe/Budapest) + +# --- Alerting thresholds --- +alerting: + stale_threshold: "30m" # Customer considered stale if no report for this duration + +# --- Server --- +server: + listen: ":8080" + data_dir: "/data" # SQLite database location diff --git a/hub/go.mod b/hub/go.mod new file mode 100644 index 0000000..fb35fcd --- /dev/null +++ b/hub/go.mod @@ -0,0 +1,23 @@ +module gitea.dooplex.hu/admin/felhom-hub + +go 1.24.0 + +require ( + golang.org/x/crypto v0.31.0 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.45.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + 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 +) diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go new file mode 100644 index 0000000..b382287 --- /dev/null +++ b/hub/internal/api/handler.go @@ -0,0 +1,184 @@ +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// Handler handles API endpoints for report ingest and customer queries. +type Handler struct { + store *store.Store + apiKey string + logger *log.Logger +} + +// New creates a new API handler. +func New(store *store.Store, apiKey string, logger *log.Logger) *Handler { + return &Handler{ + store: store, + apiKey: apiKey, + logger: logger, + } +} + +// ServeHTTP routes API requests. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1") + + switch { + case r.Method == http.MethodPost && path == "/report": + h.handleReport(w, r) + case r.Method == http.MethodGet && path == "/customers": + h.handleCustomers(w, r) + case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"): + parts := strings.Split(strings.TrimPrefix(path, "/customers/"), "/") + customerID := parts[0] + if len(parts) > 1 && parts[1] == "history" { + h.handleCustomerHistory(w, r, customerID) + } else { + h.handleCustomer(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 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit + if err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Extract customer_id from JSON + var payload struct { + CustomerID string `json:"customer_id"` + } + if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" { + http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest) + return + } + + if err := h.store.SaveReport(payload.CustomerID, body); err != nil { + h.logger.Printf("[ERROR] Failed to save report from %s: %v", payload.CustomerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + h.logger.Printf("[INFO] Received report from %s (%d bytes)", payload.CustomerID, len(body)) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) +} + +func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) { + customers, err := h.store.GetCustomers() + if err != nil { + h.logger.Printf("[ERROR] Failed to get customers: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + type customerJSON struct { + ID string `json:"id"` + Name string `json:"name"` + ControllerVersion string `json:"controller_version"` + HealthStatus string `json:"health_status"` + LastSeen time.Time `json:"last_seen"` + CPUPercent float64 `json:"cpu_percent"` + MemoryPercent float64 `json:"memory_percent"` + ContainerTotal int `json:"container_total"` + ContainerRunning int `json:"container_running"` + BackupLastSnapshot *time.Time `json:"backup_last_snapshot"` + } + + result := make([]customerJSON, 0, len(customers)) + for _, c := range customers { + result = append(result, customerJSON{ + ID: c.CustomerID, + Name: c.CustomerName, + ControllerVersion: c.ControllerVersion, + HealthStatus: c.HealthStatus, + LastSeen: c.ReceivedAt, + CPUPercent: c.CPUPercent, + MemoryPercent: c.MemoryPercent, + ContainerTotal: c.ContainerTotal, + ContainerRunning: c.ContainerRunning, + BackupLastSnapshot: c.BackupLastSnapshot, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request, customerID string) { + customer, err := h.store.GetCustomer(customerID) + if err != nil { + h.logger.Printf("[ERROR] Failed to get customer %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if customer == nil { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + // Return the full report JSON directly + w.Write([]byte(customer.ReportJSON)) +} + +func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request, customerID string) { + period := r.URL.Query().Get("period") + var since time.Duration + switch period { + case "7d": + since = 7 * 24 * time.Hour + case "30d": + since = 30 * 24 * time.Hour + default: + since = 24 * time.Hour + } + + history, err := h.store.GetCustomerHistory(customerID, since) + if err != nil { + h.logger.Printf("[ERROR] Failed to get history for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + type historyEntry struct { + ReceivedAt time.Time `json:"received_at"` + HealthStatus string `json:"health_status"` + CPUPercent float64 `json:"cpu_percent"` + MemoryPercent float64 `json:"memory_percent"` + } + + result := make([]historyEntry, 0, len(history)) + for _, h := range history { + result = append(result, historyEntry{ + ReceivedAt: h.ReceivedAt, + HealthStatus: h.HealthStatus, + CPUPercent: h.CPUPercent, + MemoryPercent: h.MemoryPercent, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go new file mode 100644 index 0000000..1f54899 --- /dev/null +++ b/hub/internal/store/store.go @@ -0,0 +1,304 @@ +package store + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "time" + + _ "modernc.org/sqlite" +) + +// Store handles SQLite persistence for customer reports. +type Store struct { + db *sql.DB + logger *log.Logger +} + +// CustomerSummary holds the latest status for a customer (for dashboard). +type CustomerSummary struct { + CustomerID string + CustomerName string + ControllerVersion string + ReceivedAt time.Time + HealthStatus string + CPUPercent float64 + MemoryPercent float64 + ContainerTotal int + ContainerRunning int + BackupLastSnapshot *time.Time + ReportJSON string + + // Computed fields (not stored) + TimeSinceReport time.Duration + DiskSummary string +} + +// New creates a new store and initializes the schema. +func New(dbPath string, logger *log.Logger) (*Store, error) { + db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + + s := &Store{db: db, logger: logger} + if err := s.migrate(); err != nil { + db.Close() + return nil, fmt.Errorf("migrating database: %w", err) + } + + return s, nil +} + +func (s *Store) migrate() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL, + received_at DATETIME NOT NULL DEFAULT (datetime('now')), + report_json TEXT NOT NULL, + health_status TEXT, + cpu_percent REAL, + memory_percent REAL, + container_total INTEGER, + container_running INTEGER, + backup_last_snapshot DATETIME, + controller_version TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_reports_customer + ON reports(customer_id, received_at DESC); + `) + return err +} + +// SaveReport stores a new report. The reportJSON should be the raw JSON payload. +func (s *Store) SaveReport(customerID string, reportJSON []byte) error { + // Parse denormalized fields from the JSON + var parsed struct { + ControllerVersion string `json:"controller_version"` + System struct { + CPUPercent float64 `json:"cpu_percent"` + MemoryPercent float64 `json:"memory_percent"` + } `json:"system"` + Containers struct { + Total int `json:"total"` + Running int `json:"running"` + } `json:"containers"` + Backup struct { + LastSnapshot *time.Time `json:"last_snapshot"` + } `json:"backup"` + Health struct { + Status string `json:"status"` + } `json:"health"` + } + json.Unmarshal(reportJSON, &parsed) + + var backupSnapshot *string + if parsed.Backup.LastSnapshot != nil { + t := parsed.Backup.LastSnapshot.Format(time.RFC3339) + backupSnapshot = &t + } + + _, err := s.db.Exec(` + INSERT INTO reports (customer_id, report_json, health_status, cpu_percent, + memory_percent, container_total, container_running, + backup_last_snapshot, controller_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + customerID, string(reportJSON), + parsed.Health.Status, parsed.System.CPUPercent, + parsed.System.MemoryPercent, parsed.Containers.Total, + parsed.Containers.Running, backupSnapshot, + parsed.ControllerVersion, + ) + return err +} + +// GetCustomers returns the latest report summary for each customer. +func (s *Store) GetCustomers() ([]CustomerSummary, error) { + rows, err := s.db.Query(` + SELECT r.customer_id, r.received_at, r.report_json, + r.health_status, r.cpu_percent, r.memory_percent, + r.container_total, r.container_running, + r.backup_last_snapshot, r.controller_version + FROM reports r + INNER JOIN ( + SELECT customer_id, MAX(received_at) as max_time + FROM reports + GROUP BY customer_id + ) latest ON r.customer_id = latest.customer_id + AND r.received_at = latest.max_time + ORDER BY r.customer_id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var customers []CustomerSummary + for rows.Next() { + var c CustomerSummary + var receivedAt string + var backupSnapshot sql.NullString + + if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON, + &c.HealthStatus, &c.CPUPercent, &c.MemoryPercent, + &c.ContainerTotal, &c.ContainerRunning, + &backupSnapshot, &c.ControllerVersion); err != nil { + return nil, err + } + + c.ReceivedAt, _ = time.Parse("2006-01-02 15:04:05", receivedAt) + c.TimeSinceReport = time.Since(c.ReceivedAt) + + if backupSnapshot.Valid { + t, err := time.Parse(time.RFC3339, backupSnapshot.String) + if err == nil { + c.BackupLastSnapshot = &t + } + } + + // Parse customer_name from JSON + var report struct { + CustomerName string `json:"customer_name"` + } + json.Unmarshal([]byte(c.ReportJSON), &report) + c.CustomerName = report.CustomerName + + // Parse disk summary + c.DiskSummary = parseDiskSummary(c.ReportJSON) + + customers = append(customers, c) + } + return customers, rows.Err() +} + +// GetCustomer returns the latest report for a specific customer. +func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) { + row := s.db.QueryRow(` + SELECT customer_id, received_at, report_json, + health_status, cpu_percent, memory_percent, + container_total, container_running, + backup_last_snapshot, controller_version + FROM reports + WHERE customer_id = ? + ORDER BY received_at DESC + LIMIT 1`, customerID) + + var c CustomerSummary + var receivedAt string + var backupSnapshot sql.NullString + + if err := row.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON, + &c.HealthStatus, &c.CPUPercent, &c.MemoryPercent, + &c.ContainerTotal, &c.ContainerRunning, + &backupSnapshot, &c.ControllerVersion); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + + c.ReceivedAt, _ = time.Parse("2006-01-02 15:04:05", receivedAt) + c.TimeSinceReport = time.Since(c.ReceivedAt) + + if backupSnapshot.Valid { + t, err := time.Parse(time.RFC3339, backupSnapshot.String) + if err == nil { + c.BackupLastSnapshot = &t + } + } + + var report struct { + CustomerName string `json:"customer_name"` + } + json.Unmarshal([]byte(c.ReportJSON), &report) + c.CustomerName = report.CustomerName + + c.DiskSummary = parseDiskSummary(c.ReportJSON) + + return &c, nil +} + +// GetCustomerHistory returns report history for a customer. +func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]CustomerSummary, error) { + cutoff := time.Now().Add(-since).Format("2006-01-02 15:04:05") + + rows, err := s.db.Query(` + SELECT customer_id, received_at, report_json, + health_status, cpu_percent, memory_percent, + container_total, container_running, + backup_last_snapshot, controller_version + FROM reports + WHERE customer_id = ? AND received_at >= ? + ORDER BY received_at DESC`, customerID, cutoff) + if err != nil { + return nil, err + } + defer rows.Close() + + var history []CustomerSummary + for rows.Next() { + var c CustomerSummary + var receivedAt string + var backupSnapshot sql.NullString + + if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON, + &c.HealthStatus, &c.CPUPercent, &c.MemoryPercent, + &c.ContainerTotal, &c.ContainerRunning, + &backupSnapshot, &c.ControllerVersion); err != nil { + return nil, err + } + + c.ReceivedAt, _ = time.Parse("2006-01-02 15:04:05", receivedAt) + c.TimeSinceReport = time.Since(c.ReceivedAt) + + if backupSnapshot.Valid { + t, err := time.Parse(time.RFC3339, backupSnapshot.String) + if err == nil { + c.BackupLastSnapshot = &t + } + } + + history = append(history, c) + } + return history, rows.Err() +} + +// Prune deletes reports older than the given number of days. +func (s *Store) Prune(maxDays int) (int64, error) { + cutoff := time.Now().AddDate(0, 0, -maxDays).Format("2006-01-02 15:04:05") + res, err := s.db.Exec("DELETE FROM reports WHERE received_at < ?", cutoff) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// Close closes the database connection. +func (s *Store) Close() error { + return s.db.Close() +} + +func parseDiskSummary(reportJSON string) string { + var report struct { + Storage []struct { + Mount string `json:"mount"` + Percent float64 `json:"percent"` + } `json:"storage"` + } + json.Unmarshal([]byte(reportJSON), &report) + + var parts []string + for _, s := range report.Storage { + parts = append(parts, fmt.Sprintf("%.0f%%", s.Percent)) + } + if len(parts) == 0 { + return "–" + } + result := parts[0] + for _, p := range parts[1:] { + result += "/" + p + } + return result +} diff --git a/hub/internal/web/embed.go b/hub/internal/web/embed.go new file mode 100644 index 0000000..f0f4a48 --- /dev/null +++ b/hub/internal/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed templates/* +var templateFS embed.FS diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go new file mode 100644 index 0000000..bc1e320 --- /dev/null +++ b/hub/internal/web/server.go @@ -0,0 +1,269 @@ +package web + +import ( + "encoding/json" + "fmt" + "html/template" + "log" + "math" + "net/http" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + "golang.org/x/crypto/bcrypt" +) + +// Server handles the dashboard web UI. +type Server struct { + store *store.Store + passwordHash string + logger *log.Logger + templates *template.Template + staleThreshold time.Duration +} + +// New creates a new web server. +func New(store *store.Store, passwordHash string, staleThreshold time.Duration, logger *log.Logger) *Server { + funcMap := template.FuncMap{ + "timeAgo": timeAgo, + "statusColor": statusColor, + "statusIcon": statusIcon, + "formatFloat": func(f float64) string { return fmt.Sprintf("%.0f", f) }, + "json": func(v interface{}) template.JS { + b, _ := json.Marshal(v) + return template.JS(b) + }, + } + + tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")) + + return &Server{ + store: store, + passwordHash: passwordHash, + logger: logger, + templates: tmpl, + staleThreshold: staleThreshold, + } +} + +// ServeHTTP routes web requests. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + switch { + case path == "/": + s.handleDashboard(w, r) + case path == "/style.css": + s.handleCSS(w, r) + case path == "/login": + s.handleLogin(w, r) + case strings.HasPrefix(path, "/customers/"): + customerID := strings.TrimPrefix(path, "/customers/") + s.handleCustomerDetail(w, r, customerID) + default: + http.NotFound(w, r) + } +} + +// RequireAuth wraps a handler with basic authentication. +func (s *Server) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip auth if no password configured + if s.passwordHash == "" { + next.ServeHTTP(w, r) + return + } + + // Check session cookie + if cookie, err := r.Cookie("hub_session"); err == nil && cookie.Value == "authenticated" { + next.ServeHTTP(w, r) + return + } + + // Check basic auth + _, password, ok := r.BasicAuth() + if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { + next.ServeHTTP(w, r) + return + } + + // Show login page for browser requests + if r.URL.Path == "/login" && r.Method == http.MethodPost { + s.handleLogin(w, r) + return + } + + w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + }) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + password := r.FormValue("password") + if bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { + http.SetCookie(w, &http.Cookie{ + Name: "hub_session", + Value: "authenticated", + Path: "/", + HttpOnly: true, + MaxAge: 86400 * 7, // 7 days + }) + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + http.Error(w, "Invalid password", http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`
`)) +} + +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { + customers, err := s.store.GetCustomers() + if err != nil { + s.logger.Printf("[ERROR] Dashboard: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + type dashboardCustomer struct { + store.CustomerSummary + OverallStatus string // "ok", "warn", "down" + BackupAge string + } + + var data []dashboardCustomer + for _, c := range customers { + dc := dashboardCustomer{CustomerSummary: c} + + // Determine overall status + if c.TimeSinceReport > time.Hour { + dc.OverallStatus = "down" + } else if c.TimeSinceReport > 30*time.Minute || c.HealthStatus == "warn" { + dc.OverallStatus = "warn" + } else if c.HealthStatus == "fail" { + dc.OverallStatus = "down" + } else { + dc.OverallStatus = "ok" + } + + // Backup age + if c.BackupLastSnapshot != nil { + dc.BackupAge = timeAgo(*c.BackupLastSnapshot) + } else { + dc.BackupAge = "–" + } + + data = append(data, dc) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.templates.ExecuteTemplate(w, "dashboard.html", data); err != nil { + s.logger.Printf("[ERROR] Template render: %v", err) + } +} + +func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, customerID string) { + customer, err := s.store.GetCustomer(customerID) + if err != nil { + s.logger.Printf("[ERROR] Customer detail: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if customer == nil { + http.NotFound(w, r) + return + } + + // Parse the full report + var report map[string]interface{} + json.Unmarshal([]byte(customer.ReportJSON), &report) + + // Get history (last 24h) + history, _ := s.store.GetCustomerHistory(customerID, 24*time.Hour) + + type detailData struct { + Customer *store.CustomerSummary + Report map[string]interface{} + History []store.CustomerSummary + OverallStatus string + } + + overallStatus := "ok" + if customer.TimeSinceReport > time.Hour { + overallStatus = "down" + } else if customer.TimeSinceReport > 30*time.Minute || customer.HealthStatus == "warn" { + overallStatus = "warn" + } else if customer.HealthStatus == "fail" { + overallStatus = "down" + } + + data := detailData{ + Customer: customer, + Report: report, + History: history, + OverallStatus: overallStatus, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.templates.ExecuteTemplate(w, "customer.html", data); err != nil { + s.logger.Printf("[ERROR] Template render: %v", err) + } +} + +func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) { + data, err := templateFS.ReadFile("templates/style.css") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/css") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(data) +} + +func timeAgo(t time.Time) string { + d := time.Since(t) + + if d < time.Minute { + return "just now" + } + if d < time.Hour { + m := int(math.Round(d.Minutes())) + return fmt.Sprintf("%d min ago", m) + } + if d < 24*time.Hour { + h := int(math.Round(d.Hours())) + return fmt.Sprintf("%dh ago", h) + } + days := int(d.Hours() / 24) + return fmt.Sprintf("%dd ago", days) +} + +func statusColor(status string) string { + switch status { + case "ok": + return "#4ade80" // green + case "warn": + return "#facc15" // yellow + case "down", "fail": + return "#f87171" // red + default: + return "#94a3b8" // gray + } +} + +func statusIcon(status string) string { + switch status { + case "ok": + return "🟢" // green circle + case "warn": + return "🟡" // yellow circle + case "down", "fail": + return "🔴" // red circle + default: + return "⚪" // white circle + } +} diff --git a/hub/internal/web/templates/customer.html b/hub/internal/web/templates/customer.html new file mode 100644 index 0000000..2118bf6 --- /dev/null +++ b/hub/internal/web/templates/customer.html @@ -0,0 +1,193 @@ + + + + + +Last report: {{timeAgo .Customer.ReceivedAt}} · Controller v{{.Customer.ControllerVersion}}
+| Name | +State | +CPU | +Memory | +
|---|---|---|---|
| {{index . "name"}} | +{{index . "state"}} | +{{printf "%.1f" (index . "cpu_percent")}}% | +{{printf "%.0f" (index . "memory_mb")}} MB | +
+ Status: {{index . "status"}} +
+ {{$issues := index . "issues"}} + {{if $issues}} +| Time | +Status | +CPU | +Memory | +
|---|---|---|---|
| {{.ReceivedAt.Format "15:04:05"}} | +{{.HealthStatus}} | +{{formatFloat .CPUPercent}}% | +{{formatFloat .MemoryPercent}}% | +
Customer Overview Dashboard
+No customer reports received yet.
+Configure hub.enabled: true in customer controller.yaml to start receiving reports.
| Customer | +Status | +Last Seen | +CPU | +Memory | +Disk | +Containers | +Last Backup | +Version | +
|---|---|---|---|---|---|---|---|---|
| + {{statusIcon .OverallStatus}} + {{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} + | ++ + {{if eq .OverallStatus "ok"}}OK{{else if eq .OverallStatus "warn"}}WARN{{else}}DOWN{{end}} + + | +{{timeAgo .ReceivedAt}} | +{{formatFloat .CPUPercent}}% | +{{formatFloat .MemoryPercent}}% | +{{.DiskSummary}} | +{{.ContainerRunning}}/{{.ContainerTotal}} | +{{.BackupAge}} | +{{.ControllerVersion}} |
+