From 77b5a4ce4e886041dd9a5c3664cf3c5f7ad1ed1d Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 16 Feb 2026 13:19:25 +0100 Subject: [PATCH] Add felhom-hub: multi-customer dashboard service - Hub service receives reports from customer controllers - SQLite store with 90-day retention and auto-prune - REST API: POST /api/v1/report, GET /api/v1/customers - Dark theme dashboard with status overview table - Customer detail page with system, storage, containers, backup, health - Bearer token auth for report ingest, bcrypt auth for dashboard - K8s manifest for felhom-system namespace (Deployment, Service, Ingress, PVC) - Dockerfile with multi-stage build Co-Authored-By: Claude Opus 4.6 --- hub/Dockerfile | 30 ++ hub/Makefile | 25 ++ hub/cmd/hub/main.go | 202 +++++++++++++ hub/configs/hub.yaml.example | 32 ++ hub/go.mod | 23 ++ hub/internal/api/handler.go | 184 ++++++++++++ hub/internal/store/store.go | 304 +++++++++++++++++++ hub/internal/web/embed.go | 6 + hub/internal/web/server.go | 269 +++++++++++++++++ hub/internal/web/templates/customer.html | 193 ++++++++++++ hub/internal/web/templates/dashboard.html | 67 +++++ hub/internal/web/templates/style.css | 348 ++++++++++++++++++++++ manifests/hub.yaml | 133 +++++++++ 13 files changed, 1816 insertions(+) create mode 100644 hub/Dockerfile create mode 100644 hub/Makefile create mode 100644 hub/cmd/hub/main.go create mode 100644 hub/configs/hub.yaml.example create mode 100644 hub/go.mod create mode 100644 hub/internal/api/handler.go create mode 100644 hub/internal/store/store.go create mode 100644 hub/internal/web/embed.go create mode 100644 hub/internal/web/server.go create mode 100644 hub/internal/web/templates/customer.html create mode 100644 hub/internal/web/templates/dashboard.html create mode 100644 hub/internal/web/templates/style.css create mode 100644 manifests/hub.yaml 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 @@ + + + + + + {{.Customer.CustomerName}} — Felhom Hub + + + + +
+
+ ← Back to Dashboard +

+ {{statusIcon .OverallStatus}} + {{.Customer.CustomerName}} +

+

Last report: {{timeAgo .Customer.ReceivedAt}} · Controller v{{.Customer.ControllerVersion}}

+
+ + +
+

System

+
+ {{with .Report.system}} +
+ Hostname + {{index . "hostname"}} +
+
+ OS + {{index . "os"}} +
+
+ Kernel + {{index . "kernel"}} +
+
+ CPU + {{index . "cpu_model"}} ({{index . "cpu_cores"}} cores) +
+ {{end}} +
+
+
+ CPU + {{formatFloat .Customer.CPUPercent}}% +
+
+
+ Memory + {{formatFloat .Customer.MemoryPercent}}% +
+
+
+
+ + +
+

Storage

+ {{with .Report.storage}} +
+ {{range .}} +
+ {{index . "mount"}} + {{printf "%.0f" (index . "percent")}}% +
+ {{printf "%.1f" (index . "used_gb")}} / {{printf "%.1f" (index . "total_gb")}} GB +
+ {{end}} +
+ {{end}} +
+ + +
+

Containers ({{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}})

+ {{with .Report.containers}} + {{$list := index . "list"}} + {{if $list}} + + + + + + + + + + + {{range $list}} + + + + + + + {{end}} + +
NameStateCPUMemory
{{index . "name"}}{{index . "state"}}{{printf "%.1f" (index . "cpu_percent")}}%{{printf "%.0f" (index . "memory_mb")}} MB
+ {{end}} + {{end}} +
+ + +
+

Backup

+ {{with .Report.backup}} +
+
+ Enabled + {{if index . "enabled"}}Yes{{else}}No{{end}} +
+
+ Snapshots + {{index . "snapshot_count"}} +
+
+ Repo Size + {{index . "repo_size_mb"}} MB +
+
+ Integrity + {{if index . "integrity_ok"}}OK{{else}}Unknown{{end}} +
+
+ {{end}} +
+ + +
+

Health

+ {{with .Report.health}} +

+ Status: {{index . "status"}} +

+ {{$issues := index . "issues"}} + {{if $issues}} +

Issues

+
    + {{range $issues}} +
  • {{.}}
  • + {{end}} +
+ {{end}} + {{$warnings := index . "warnings"}} + {{if $warnings}} +

Warnings

+
    + {{range $warnings}} +
  • {{.}}
  • + {{end}} +
+ {{end}} + {{end}} +
+ + + {{if .History}} +
+

Report History (last 24h)

+
+ {{len .History}} reports + + + + + + + + + + + {{range .History}} + + + + + + + {{end}} + +
TimeStatusCPUMemory
{{.ReceivedAt.Format "15:04:05"}}{{.HealthStatus}}{{formatFloat .CPUPercent}}%{{formatFloat .MemoryPercent}}%
+
+
+ {{end}} + +
+

Auto-refreshes every 60 seconds · Felhom Hub

+
+
+ + diff --git a/hub/internal/web/templates/dashboard.html b/hub/internal/web/templates/dashboard.html new file mode 100644 index 0000000..d8cab27 --- /dev/null +++ b/hub/internal/web/templates/dashboard.html @@ -0,0 +1,67 @@ + + + + + + Felhom Hub — Customer Overview + + + + +
+
+

Felhom Hub

+

Customer Overview Dashboard

+
+ + {{if not .}} +
+

No customer reports received yet.

+

Configure hub.enabled: true in customer controller.yaml to start receiving reports.

+
+ {{else}} + + + + + + + + + + + + + + + + {{range .}} + + + + + + + + + + + + {{end}} + +
CustomerStatusLast SeenCPUMemoryDiskContainersLast BackupVersion
+ {{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}}
+ {{end}} + + +
+ + diff --git a/hub/internal/web/templates/style.css b/hub/internal/web/templates/style.css new file mode 100644 index 0000000..bd52c6a --- /dev/null +++ b/hub/internal/web/templates/style.css @@ -0,0 +1,348 @@ +/* Felhom Hub — Dark theme */ +:root { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-card: #1e293b; + --bg-hover: #334155; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --border: #334155; + --accent: #60a5fa; + --green: #4ade80; + --yellow: #facc15; + --red: #f87171; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +/* Header */ +header { + margin-bottom: 2rem; +} + +header h1 { + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary); +} + +.subtitle { + color: var(--text-secondary); + font-size: 0.9rem; + margin-top: 0.25rem; +} + +.back-link { + color: var(--accent); + text-decoration: none; + font-size: 0.9rem; + display: inline-block; + margin-bottom: 0.5rem; +} + +.back-link:hover { + text-decoration: underline; +} + +/* Dashboard table */ +.dashboard-table { + width: 100%; + border-collapse: collapse; + background: var(--bg-card); + border-radius: 8px; + overflow: hidden; +} + +.dashboard-table th { + text-align: left; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--border); +} + +.dashboard-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; +} + +.dashboard-table tbody tr { + cursor: pointer; + transition: background 0.15s; +} + +.dashboard-table tbody tr:hover { + background: var(--bg-hover); +} + +.dashboard-table tbody tr:last-child td { + border-bottom: none; +} + +.customer-name { + font-weight: 600; +} + +.status-dot { + font-size: 0.85rem; + margin-right: 0.25rem; +} + +/* Status badges */ +.status-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.status-badge-ok { background: rgba(74, 222, 128, 0.15); color: var(--green); } +.status-badge-warn { background: rgba(250, 204, 21, 0.15); color: var(--yellow); } +.status-badge-down, .status-badge-fail { background: rgba(248, 113, 113, 0.15); color: var(--red); } + +/* Cards */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.25rem; + margin-bottom: 1rem; +} + +.card h2 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.card h3 { + font-size: 0.9rem; + font-weight: 600; + margin-top: 0.75rem; + margin-bottom: 0.5rem; + color: var(--text-secondary); +} + +/* Info grid */ +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.75rem; +} + +.info-item .label { + display: block; + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.info-item .value { + font-size: 0.9rem; + color: var(--text-primary); +} + +/* Metrics */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; + margin-top: 0.75rem; +} + +.metric { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.metric-label { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.metric-value { + font-size: 1.25rem; + font-weight: 700; + font-family: var(--font-mono); +} + +.metric-detail { + font-size: 0.8rem; + color: var(--text-muted); +} + +.bar { + height: 6px; + background: var(--border); + border-radius: 3px; + overflow: hidden; +} + +.bar-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.3s ease; +} + +/* Container table */ +.container-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.container-table th { + text-align: left; + padding: 0.5rem 0.75rem; + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + border-bottom: 1px solid var(--border); +} + +.container-table td { + padding: 0.4rem 0.75rem; + border-bottom: 1px solid rgba(51, 65, 85, 0.5); +} + +.container-state { + font-size: 0.8rem; + font-weight: 600; +} + +.container-state-running { color: var(--green); } +.container-state-stopped, .container-state-exited { color: var(--red); } +.container-state-unhealthy { color: var(--yellow); } + +/* Health */ +.health-status { + font-size: 1.1rem; + font-weight: 700; + text-transform: uppercase; +} + +.health-status-ok { color: var(--green); } +.health-status-warn { color: var(--yellow); } +.health-status-fail { color: var(--red); } + +.issue-list, .warning-list { + list-style: none; + padding-left: 0; +} + +.issue-list li::before { content: "● "; color: var(--red); } +.warning-list li::before { content: "● "; color: var(--yellow); } + +.issue, .warning { + padding: 0.25rem 0; + font-size: 0.9rem; +} + +/* History */ +.history-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.history-table th { + text-align: left; + padding: 0.4rem 0.5rem; + color: var(--text-muted); + font-size: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.history-table td { + padding: 0.3rem 0.5rem; + border-bottom: 1px solid rgba(51, 65, 85, 0.3); +} + +details summary { + cursor: pointer; + color: var(--accent); + font-size: 0.9rem; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 4rem 2rem; + color: var(--text-secondary); +} + +.empty-state .hint { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--text-muted); +} + +.empty-state code { + background: var(--bg-hover); + padding: 0.15rem 0.4rem; + border-radius: 4px; + font-family: var(--font-mono); + font-size: 0.85rem; +} + +/* Footer */ +footer { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + text-align: center; + font-size: 0.8rem; + color: var(--text-muted); +} + +footer a { + color: var(--accent); + text-decoration: none; +} + +/* Code */ +code { + font-family: var(--font-mono); + font-size: 0.85em; + color: var(--accent); +} + +/* Responsive */ +@media (max-width: 768px) { + .container { padding: 1rem; } + .dashboard-table { font-size: 0.8rem; } + .dashboard-table th, .dashboard-table td { padding: 0.5rem; } + .info-grid { grid-template-columns: 1fr 1fr; } + .metrics-grid { grid-template-columns: 1fr; } +} diff --git a/manifests/hub.yaml b/manifests/hub.yaml new file mode 100644 index 0000000..96fc6fa --- /dev/null +++ b/manifests/hub.yaml @@ -0,0 +1,133 @@ +# Felhom Hub — Multi-customer dashboard +# Dashboard: https://hub.felhom.eu +# API: POST /api/v1/report (Bearer token auth) +# +# Receives health reports from customer controllers and displays +# a centralized overview dashboard for the operator (Viktor). +# +# Namespace: felhom-system (shared with healthchecks and other felhom infra) +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: hub-data + namespace: felhom-system +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: hub-config + namespace: felhom-system +data: + hub.yaml: | + auth: + password_hash: "" + api: + report_api_key: "" + retention: + max_days: 90 + prune_schedule: "04:30" + alerting: + stale_threshold: "30m" + server: + listen: ":8080" + data_dir: "/data" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hub + namespace: felhom-system + labels: + app: hub +spec: + replicas: 1 + selector: + matchLabels: + app: hub + template: + metadata: + labels: + app: hub + spec: + containers: + - name: hub + image: gitea.dooplex.hu/admin/felhom-hub:latest + ports: + - containerPort: 8080 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + volumeMounts: + - name: data + mountPath: /data + - name: config + mountPath: /etc/felhom-hub + livenessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 10 + volumes: + - name: data + persistentVolumeClaim: + claimName: hub-data + - name: config + configMap: + name: hub-config +--- +apiVersion: v1 +kind: Service +metadata: + name: hub + namespace: felhom-system +spec: + selector: + app: hub + ports: + - port: 8080 + targetPort: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: hub + namespace: felhom-system + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/proxy-body-size: "2m" +spec: + ingressClassName: nginx-internal + tls: + - hosts: + - hub.felhom.eu + secretName: hub-felhom-eu-tls + rules: + - host: hub.felhom.eu + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: hub + port: + number: 8080