From 42e0617a6c2bbfb9fde186dc169b63512043a083 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 20 Feb 2026 15:57:39 +0100 Subject: [PATCH] hub: unified customer page, blocked status, dashboard merge - Replace separate config detail and report detail pages with unified /customers/{id} page showing both config info and live report data - Add "blocked" status for customers (hidden from dashboard, notifications suppressed, still accepts reports) - Dashboard now shows config-only customers as "PENDING" status - Customers list: all rows link to /customers/{id}, show BLOCKED badge - New actions: block/unblock, push config to controller, auto-create config from report data - /configs/{id} now redirects to /customers/{id} - Add config-badge CSS classes for MANAGED/MANUAL/BLOCKED badges Co-Authored-By: Claude Opus 4.6 --- hub/internal/api/handler.go | 9 + hub/internal/store/store.go | 36 +- hub/internal/web/configs.go | 364 ++++++++++-- hub/internal/web/server.go | 157 ++--- hub/internal/web/templates/config_form.html | 4 +- hub/internal/web/templates/configs.html | 14 +- .../web/templates/customer_unified.html | 558 ++++++++++++++++++ hub/internal/web/templates/dashboard.html | 14 +- hub/internal/web/templates/style.css | 29 + 9 files changed, 1017 insertions(+), 168 deletions(-) create mode 100644 hub/internal/web/templates/customer_unified.html diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 16f489c..f39d461 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -255,6 +255,15 @@ func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) { h.logger.Printf("[INFO] Notification from %s: %s (%s) — %s", payload.CustomerID, payload.EventType, payload.Severity, payload.Message) + // Check if customer is blocked + if h.store.IsCustomerBlocked(payload.CustomerID) { + h.logger.Printf("[INFO] Notification suppressed for blocked customer %s", payload.CustomerID) + h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "customer blocked") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok","sent":false,"reason":"blocked"}`)) + return + } + // Look up customer notification preferences prefs, err := h.store.GetNotificationPrefs(payload.CustomerID) if err != nil { diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index e7e2ac6..ff64a79 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -118,6 +118,9 @@ func (s *Store) migrate() error { // v0.1.8: add controller_url column (idempotent — ignore error if already exists) s.db.Exec("ALTER TABLE reports ADD COLUMN controller_url TEXT") + // v0.2.1: add status column to customer_configs (idempotent) + s.db.Exec("ALTER TABLE customer_configs ADD COLUMN status TEXT NOT NULL DEFAULT 'active'") + return nil } @@ -520,6 +523,7 @@ type CustomerConfig struct { RetrievalPassword string APIKey string ConfigJSON string // JSON object with customer-specific override fields + Status string // "active" or "blocked" CreatedAt time.Time UpdatedAt time.Time } @@ -550,11 +554,11 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) { 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 + retrieval_password, api_key, config_json, status, 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, + &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &createdAt, &updatedAt) if err == sql.ErrNoRows { return nil, nil @@ -571,7 +575,7 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) { 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 + retrieval_password, api_key, config_json, status, created_at, updated_at FROM customer_configs ORDER BY customer_id`) if err != nil { return nil, err @@ -583,7 +587,7 @@ func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) { 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, + &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &createdAt, &updatedAt); err != nil { return nil, err } @@ -607,11 +611,11 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error 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 + retrieval_password, api_key, config_json, status, 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, + &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &createdAt, &updatedAt) if err == sql.ErrNoRows { return nil, nil @@ -624,6 +628,26 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error return &cfg, nil } +// SetCustomerConfigStatus sets the status (active/blocked) for a customer config. +func (s *Store) SetCustomerConfigStatus(customerID, status string) error { + _, err := s.db.Exec(` + UPDATE customer_configs SET status = ?, updated_at = datetime('now') + WHERE customer_id = ?`, + status, customerID, + ) + return err +} + +// IsCustomerBlocked returns true if the customer config has status "blocked". +func (s *Store) IsCustomerBlocked(customerID string) bool { + var status string + err := s.db.QueryRow( + "SELECT status FROM customer_configs WHERE customer_id = ?", + customerID, + ).Scan(&status) + return err == nil && status == "blocked" +} + // UpdateRetrievalPassword updates the retrieval password for a customer config. func (s *Store) UpdateRetrievalPassword(customerID, newPassword string) error { _, err := s.db.Exec(` diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index f54f473..5711a82 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -3,6 +3,7 @@ package web import ( "encoding/json" "fmt" + "io" "net/http" "regexp" "sort" @@ -21,7 +22,8 @@ type customerListEntry struct { CustomerName string Domain string HasConfig bool - OverallStatus string // ok, warn, down, disabled, "" if no reports + IsBlocked bool + OverallStatus string // ok, warn, down, disabled, pending, "" if no reports ControllerVersion string TimeSinceReport time.Duration ConfigCreatedAt time.Time @@ -52,6 +54,7 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) { CustomerName: cfg.CustomerName, Domain: cfg.Domain, HasConfig: true, + IsBlocked: cfg.Status == "blocked", ConfigCreatedAt: cfg.CreatedAt, } } @@ -109,6 +112,167 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) { s.templates.ExecuteTemplate(w, "configs.html", data) } +// handleCustomerUnified shows the unified customer detail page (config + reports). +func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, customerID string) { + cfg, _ := s.store.GetCustomerConfig(customerID) + customer, _ := s.store.GetCustomer(customerID) + + // 404 if neither config nor reports exist + if cfg == nil && customer == nil { + http.NotFound(w, r) + return + } + + // Determine identity fields from best source + name := "" + domain := "" + email := "" + if cfg != nil { + name = cfg.CustomerName + domain = cfg.Domain + email = cfg.Email + } + if name == "" && customer != nil { + name = customer.CustomerName + } + + // Parse report JSON + var report map[string]interface{} + if customer != nil { + json.Unmarshal([]byte(customer.ReportJSON), &report) + } + + // Parse config overrides + var overrides map[string]interface{} + if cfg != nil { + json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) + } + + // Overall status + overallStatus := "pending" + if customer != nil { + if customer.HealthStatus == "disabled" { + overallStatus = "disabled" + } else 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" + } else { + overallStatus = "ok" + } + } + if cfg != nil && cfg.Status == "blocked" { + overallStatus = "blocked" + } + + // Controller URL + controllerURL := "" + if customer != nil { + controllerURL = customer.ControllerURL + if controllerURL == "" { + var rpt struct { + ControllerURL string `json:"controller_url"` + } + json.Unmarshal([]byte(customer.ReportJSON), &rpt) + controllerURL = rpt.ControllerURL + } + } + + // Version check + var latestVersion string + var updateAvailable bool + if s.versionChecker != nil && customer != nil { + latestVersion = s.versionChecker.LatestVersion() + if latestVersion != "" && customer.ControllerVersion != "" { + updateAvailable = latestVersion != customer.ControllerVersion && compareVersions(latestVersion, customer.ControllerVersion) > 0 + } + } + + // History, notifications, infra backup + var history []store.CustomerSummary + var notifPrefs *store.NotificationPrefs + var recentNotifs []store.NotificationLogEntry + var infraMeta *store.InfraBackupMeta + var infraBackupAge string + + if customer != nil { + history, _ = s.store.GetCustomerHistory(customerID, 24*time.Hour) + notifPrefs, _ = s.store.GetNotificationPrefs(customerID) + recentNotifs, _ = s.store.GetRecentNotifications(customerID, 10) + infraMeta, _ = s.store.GetInfraBackupMeta(customerID) + if infraMeta != nil { + infraBackupAge = timeAgo(infraMeta.UpdatedAt) + } + } + + type pageData struct { + CustomerID string + CustomerName string + Domain string + Email string + + HasConfig bool + Config *store.CustomerConfig + Overrides map[string]interface{} + IsBlocked bool + + HasReports bool + Customer *store.CustomerSummary + Report map[string]interface{} + OverallStatus string + + LatestVersion string + UpdateAvailable bool + ControllerURL string + + InfraBackup *store.InfraBackupMeta + InfraBackupAge string + NotifPrefs *store.NotificationPrefs + RecentNotifications []store.NotificationLogEntry + History []store.CustomerSummary + + Flash string + ActiveNav string + } + + data := pageData{ + CustomerID: customerID, + CustomerName: name, + Domain: domain, + Email: email, + + HasConfig: cfg != nil, + Config: cfg, + Overrides: overrides, + IsBlocked: cfg != nil && cfg.Status == "blocked", + + HasReports: customer != nil, + Customer: customer, + Report: report, + OverallStatus: overallStatus, + + LatestVersion: latestVersion, + UpdateAvailable: updateAvailable, + ControllerURL: controllerURL, + + InfraBackup: infraMeta, + InfraBackupAge: infraBackupAge, + NotifPrefs: notifPrefs, + RecentNotifications: recentNotifs, + History: history, + + Flash: r.URL.Query().Get("flash"), + ActiveNav: "configs", + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.templates.ExecuteTemplate(w, "customer_unified.html", data); err != nil { + s.logger.Printf("[ERROR] Template render: %v", err) + } +} + // handleConfigNewForm shows the form to create a new customer config. func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) { data := struct { @@ -187,38 +351,7 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) { } 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) + http.Redirect(w, r, "/customers/"+customerID+"?flash=created", http.StatusSeeOther) } // handleConfigEditForm shows the edit form for a customer config. @@ -272,7 +405,7 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust } s.logger.Printf("[INFO] Customer config updated: %s", customerID) - http.Redirect(w, r, "/configs/"+customerID+"?flash=updated", http.StatusSeeOther) + http.Redirect(w, r, "/customers/"+customerID+"?flash=updated", http.StatusSeeOther) } // handleConfigDelete deletes a customer config. @@ -326,7 +459,152 @@ func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Reques } s.logger.Printf("[INFO] Retrieval password regenerated for %s", customerID) - http.Redirect(w, r, "/configs/"+customerID+"?flash=password_regenerated", http.StatusSeeOther) + http.Redirect(w, r, "/customers/"+customerID+"?flash=password_regenerated", http.StatusSeeOther) +} + +// handleBlockCustomer sets a customer's status to "blocked". +func (s *Server) handleBlockCustomer(w http.ResponseWriter, r *http.Request, customerID string) { + cfg, _ := s.store.GetCustomerConfig(customerID) + if cfg == nil { + http.NotFound(w, r) + return + } + if err := s.store.SetCustomerConfigStatus(customerID, "blocked"); err != nil { + s.logger.Printf("[ERROR] Failed to block %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + s.logger.Printf("[INFO] Customer blocked: %s", customerID) + http.Redirect(w, r, "/customers/"+customerID+"?flash=blocked", http.StatusSeeOther) +} + +// handleUnblockCustomer sets a customer's status back to "active". +func (s *Server) handleUnblockCustomer(w http.ResponseWriter, r *http.Request, customerID string) { + cfg, _ := s.store.GetCustomerConfig(customerID) + if cfg == nil { + http.NotFound(w, r) + return + } + if err := s.store.SetCustomerConfigStatus(customerID, "active"); err != nil { + s.logger.Printf("[ERROR] Failed to unblock %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + s.logger.Printf("[INFO] Customer unblocked: %s", customerID) + http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked", http.StatusSeeOther) +} + +// handlePushConfig sends the generated YAML config to the controller. +func (s *Server) handlePushConfig(w http.ResponseWriter, r *http.Request, customerID string) { + cfg, err := s.store.GetCustomerConfig(customerID) + if err != nil || cfg == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"ok":false,"error":"No config found for this customer"}`)) + return + } + + // Get controller URL from latest report + customer, _ := s.store.GetCustomer(customerID) + controllerURL := "" + if customer != nil { + controllerURL = customer.ControllerURL + if controllerURL == "" { + var rpt struct { + ControllerURL string `json:"controller_url"` + } + json.Unmarshal([]byte(customer.ReportJSON), &rpt) + controllerURL = rpt.ControllerURL + } + } + if controllerURL == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"ok":false,"error":"Controller URL not available — waiting for first report"}`)) + return + } + + // Generate YAML + 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 config for push to %s: %v", customerID, err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"ok":false,"error":"Failed to generate config"}`)) + return + } + + // POST to controller + pushURL := controllerURL + "/api/config/apply" + req, err := http.NewRequest("POST", pushURL, strings.NewReader(yamlOutput)) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"ok":false,"error":"Failed to create request"}`)) + return + } + req.Header.Set("Authorization", "Bearer "+s.apiKey) + req.Header.Set("Content-Type", "text/yaml") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + s.logger.Printf("[ERROR] Push config to %s failed: %v", pushURL, err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + s.logger.Printf("[INFO] Push config to %s — controller responded %d: %s", customerID, resp.StatusCode, string(body)) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) +} + +// handleCreateConfigFromReport auto-creates a config entry from report data. +func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Request, customerID string) { + // Check if config already exists + existing, _ := s.store.GetCustomerConfig(customerID) + if existing != nil { + http.Redirect(w, r, "/configs/"+customerID+"/edit", http.StatusSeeOther) + return + } + + // Get report data to pre-fill + customer, _ := s.store.GetCustomer(customerID) + name := customerID + if customer != nil && customer.CustomerName != "" { + name = customer.CustomerName + } + + // Generate credentials + retrievalPassword, _ := configgen.RandomHex(32) + apiKey, _ := configgen.RandomHex(32) + + cfg := &store.CustomerConfig{ + CustomerID: customerID, + CustomerName: name, + RetrievalPassword: retrievalPassword, + APIKey: apiKey, + ConfigJSON: "{}", + } + + if err := s.store.SaveCustomerConfig(cfg); err != nil { + s.logger.Printf("[ERROR] Failed to create config from report for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + s.logger.Printf("[INFO] Config auto-created from report for %s", customerID) + http.Redirect(w, r, "/configs/"+customerID+"/edit", http.StatusSeeOther) } // renderConfigForm is a helper to re-render the form with an error. @@ -395,19 +673,3 @@ func buildConfigJSON(r *http.Request) string { 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 "" -} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index be10917..d562048 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -83,9 +83,41 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } + case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/block"): + customerID := strings.TrimPrefix(path, "/customers/") + customerID = strings.TrimSuffix(customerID, "/block") + if r.Method == http.MethodPost { + s.handleBlockCustomer(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/unblock"): + customerID := strings.TrimPrefix(path, "/customers/") + customerID = strings.TrimSuffix(customerID, "/unblock") + if r.Method == http.MethodPost { + s.handleUnblockCustomer(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/push-config"): + customerID := strings.TrimPrefix(path, "/customers/") + customerID = strings.TrimSuffix(customerID, "/push-config") + if r.Method == http.MethodPost { + s.handlePushConfig(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/create-config"): + customerID := strings.TrimPrefix(path, "/customers/") + customerID = strings.TrimSuffix(customerID, "/create-config") + if r.Method == http.MethodPost { + s.handleCreateConfigFromReport(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } case strings.HasPrefix(path, "/customers/"): customerID := strings.TrimPrefix(path, "/customers/") - s.handleCustomerDetail(w, r, customerID) + s.handleCustomerUnified(w, r, customerID) // Config management routes — exact matches first, then prefix matches case path == "/configs": s.handleConfigList(w, r) @@ -124,8 +156,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/configs/"): + // Redirect old config detail URL to unified customer page customerID := strings.TrimPrefix(path, "/configs/") - s.handleConfigDetail(w, r, customerID) + http.Redirect(w, r, "/customers/"+customerID, http.StatusSeeOther) default: http.NotFound(w, r) } @@ -193,14 +226,24 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { return } + configs, _ := s.store.ListCustomerConfigs() + type dashboardCustomer struct { store.CustomerSummary - OverallStatus string // "ok", "warn", "down" + OverallStatus string // "ok", "warn", "down", "pending" BackupAge string } + // Build map of report customers keyed by ID + seen := make(map[string]bool) var data []dashboardCustomer for _, c := range customers { + // Skip blocked customers + if s.store.IsCustomerBlocked(c.CustomerID) { + continue + } + + seen[c.CustomerID] = true dc := dashboardCustomer{CustomerSummary: c} // Determine overall status @@ -226,104 +269,24 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { 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) - - // Get notification preferences and recent log - notifPrefs, _ := s.store.GetNotificationPrefs(customerID) - recentNotifs, _ := s.store.GetRecentNotifications(customerID, 10) - - // Get infra backup metadata - infraMeta, _ := s.store.GetInfraBackupMeta(customerID) - - type detailData struct { - Customer *store.CustomerSummary - Report map[string]interface{} - History []store.CustomerSummary - OverallStatus string - NotifPrefs *store.NotificationPrefs - RecentNotifications []store.NotificationLogEntry - InfraBackup *store.InfraBackupMeta - InfraBackupAge string - ControllerURL string // controller's external URL - LatestVersion string // latest controller image version from registry - UpdateAvailable bool // true if latest > current - } - - // Get controller URL (from denormalized field or report JSON fallback) - controllerURL := customer.ControllerURL - if controllerURL == "" { - var rpt struct { - ControllerURL string `json:"controller_url"` + // Add config-only customers (no reports yet) as "pending" + for _, cfg := range configs { + if seen[cfg.CustomerID] || cfg.Status == "blocked" { + continue } - json.Unmarshal([]byte(customer.ReportJSON), &rpt) - controllerURL = rpt.ControllerURL - } - - // Check if update is available - var latestVersion string - var updateAvailable bool - if s.versionChecker != nil { - latestVersion = s.versionChecker.LatestVersion() - if latestVersion != "" && customer.ControllerVersion != "" { - updateAvailable = latestVersion != customer.ControllerVersion && compareVersions(latestVersion, customer.ControllerVersion) > 0 + dc := dashboardCustomer{ + CustomerSummary: store.CustomerSummary{ + CustomerID: cfg.CustomerID, + CustomerName: cfg.CustomerName, + }, + OverallStatus: "pending", + BackupAge: "–", } - } - - overallStatus := "ok" - if customer.HealthStatus == "disabled" { - overallStatus = "disabled" - } else 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" - } - - var infraBackupAge string - if infraMeta != nil { - infraBackupAge = timeAgo(infraMeta.UpdatedAt) - } - - data := detailData{ - Customer: customer, - Report: report, - History: history, - OverallStatus: overallStatus, - NotifPrefs: notifPrefs, - RecentNotifications: recentNotifs, - InfraBackup: infraMeta, - InfraBackupAge: infraBackupAge, - ControllerURL: controllerURL, - LatestVersion: latestVersion, - UpdateAvailable: updateAvailable, + data = append(data, dc) } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := s.templates.ExecuteTemplate(w, "customer.html", data); err != nil { + if err := s.templates.ExecuteTemplate(w, "dashboard.html", data); err != nil { s.logger.Printf("[ERROR] Template render: %v", err) } } @@ -455,7 +418,7 @@ func statusColor(status string) string { return "#facc15" // yellow case "down", "fail": return "#f87171" // red - case "disabled": + case "disabled", "pending", "blocked": return "#94a3b8" // gray default: return "#94a3b8" // gray diff --git a/hub/internal/web/templates/config_form.html b/hub/internal/web/templates/config_form.html index d530b8b..1dae7f8 100644 --- a/hub/internal/web/templates/config_form.html +++ b/hub/internal/web/templates/config_form.html @@ -16,7 +16,7 @@ - ← Back + ← Back

{{if .IsNew}}Add Customer{{else}}Edit: {{.Config.CustomerID}}{{end}}

{{if .Error}} @@ -136,7 +136,7 @@
- Cancel + Cancel
diff --git a/hub/internal/web/templates/configs.html b/hub/internal/web/templates/configs.html index 6e7509f..8ce7b4e 100644 --- a/hub/internal/web/templates/configs.html +++ b/hub/internal/web/templates/configs.html @@ -46,13 +46,17 @@ {{range .Customers}} - + {{.CustomerID}} {{if .CustomerName}}{{.CustomerName}}{{else}}{{end}} {{if .Domain}}{{.Domain}}{{else}}{{end}} - {{if .OverallStatus}} - {{.OverallStatus}} + {{if .IsBlocked}} + BLOCKED + {{else if .OverallStatus}} + + {{if eq .OverallStatus "ok"}}OK{{else if eq .OverallStatus "warn"}}WARN{{else if eq .OverallStatus "down"}}DOWN{{else if eq .OverallStatus "disabled"}}PAUSED{{else if eq .OverallStatus "pending"}}PENDING{{else}}{{.OverallStatus}}{{end}} + {{else}} {{end}} @@ -60,9 +64,9 @@ {{if .ControllerVersion}}{{.ControllerVersion}}{{else}}{{end}} {{if .HasConfig}} - managed + MANAGED {{else}} - manual + MANUAL {{end}} diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html new file mode 100644 index 0000000..3f6a2ce --- /dev/null +++ b/hub/internal/web/templates/customer_unified.html @@ -0,0 +1,558 @@ + + + + + + {{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} — Felhom Hub + + {{if .HasReports}}{{end}} + + +
+
+ + ← All Customers +

+ {{statusIcon .OverallStatus}} + {{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} +

+ {{if .HasReports}} +

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

+ {{else}} +

No reports received yet

+ {{end}} +
+ + {{if .Flash}} +
+ {{if eq .Flash "created"}}Configuration created successfully. + {{else if eq .Flash "updated"}}Configuration updated. + {{else if eq .Flash "password_regenerated"}}Retrieval password regenerated. + {{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard. + {{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again. + {{end}} +
+ {{end}} + + {{if .IsBlocked}} +
+ This customer is blocked — reports are accepted but not shown on the Dashboard. +
+ {{end}} + + +
+
+

Customer Info

+
+ {{if .HasConfig}} + Edit + {{if .IsBlocked}} +
+ +
+ {{else}} +
+ +
+ {{end}} +
+ +
+ {{else}} +
+ +
+ {{end}} +
+
+
+
+ Customer ID + {{.CustomerID}} +
+
+ Name + {{if .CustomerName}}{{.CustomerName}}{{else}}—{{end}} +
+
+ Domain + {{if .Domain}}{{.Domain}}{{else}}—{{end}} +
+
+ Email + {{if .Email}}{{.Email}}{{else}}—{{end}} +
+
+ Config + + {{if .HasConfig}} + MANAGED + {{else}} + MANUAL + {{end}} + {{if .IsBlocked}}BLOCKED{{end}} + +
+ {{if .HasConfig}} +
+ Config Created + {{timeAgo .Config.CreatedAt}} +
+ {{end}} +
+
+ + {{if .HasReports}} + +
+

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 .}} +
+ {{with index . "label"}}{{.}}{{else}}{{index . "mount"}}{{end}} + {{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}} +
+ + +
+

Infra Backup

+ {{if .InfraBackup}} +
+
+ Last Updated + {{.InfraBackupAge}} +
+
+ Deployed Stacks + {{.InfraBackup.StackCount}} +
+
+ Disks + {{.InfraBackup.DiskCount}} +
+
+ {{else}} +

No infra backup received yet

+ {{end}} +
+ + +
+

Health

+ {{if eq .OverallStatus "disabled"}} +

Reporting has been disabled on this node

+

Enable it in the controller's controller.yaml: hub.enabled: true

+ {{else if eq .OverallStatus "blocked"}} +

Customer is blocked

+ {{else}} + {{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}} + {{end}} +
+ + {{else}} + + {{if .HasConfig}} +
+

Waiting for First Report

+

This customer has been configured but no controller report has been received yet.

+

Use one of the setup commands below to deploy the controller on the customer node.

+
+ {{end}} + {{end}} + + + {{if .HasConfig}} +
+

Credentials

+
+
+ Retrieval Password +
+ {{.Config.RetrievalPassword}} + +
+
+
+ +
+
+
+
+ API Key +
+ {{.Config.APIKey}} + +
+
+ Used by the controller for ongoing hub communication (reports, notifications, backups) +
+
+ +
+

Setup Commands

+

Use one of these methods to configure a customer node:

+ +

Option 1: docker-setup.sh (recommended)

+
+ sudo ./docker-setup.sh --hub-customer {{.CustomerID}} --hub-password {{.Config.RetrievalPassword}} + +
+ +

Option 2: Direct download

+
+ curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml + +
+
+ +
+

YAML Preview

+
+

Loading preview...

+
+
+ {{end}} + + {{if .HasReports}} + +
+

Controller Update

+
+
+ Current version + v{{.Customer.ControllerVersion}} +
+ {{if .LatestVersion}} +
+ Latest version + + v{{.LatestVersion}} + {{if .UpdateAvailable}} + ● update available + {{else}} + — up to date + {{end}} + +
+ {{end}} + {{if .ControllerURL}} +
+ Controller URL + {{.ControllerURL}} +
+ {{end}} +
+
+ {{if and .ControllerURL .UpdateAvailable}} + + {{else if and .ControllerURL (not .LatestVersion)}} + + {{end}} + {{if and .HasConfig .ControllerURL}} + + {{end}} + +
+ {{if and .ControllerURL (not .LatestVersion)}} +

Registry check not configured — cannot verify if update is available

+ {{end}} +
+ + +
+

Notifications

+
+
+ Email + {{if .NotifPrefs}}{{if .NotifPrefs.Email}}{{.NotifPrefs.Email}}{{else}}Not set{{end}}{{else}}Not configured{{end}} +
+ {{if .NotifPrefs}} +
+ Events + {{if .NotifPrefs.EnabledEvents}}{{joinStrings .NotifPrefs.EnabledEvents ", "}}{{else}}None{{end}} +
+ {{end}} +
+ {{if .RecentNotifications}} +

Recent (last 10)

+ + + + + + + + + + + {{range .RecentNotifications}} + + + + + + + {{end}} + +
TimeEventStatusMessage
{{.CreatedAt.Format "Jan 02 15:04"}}{{.EventType}}{{.Status}}{{.Message}}
+ {{end}} +
+ + + {{if .History}} +
+

Report History (last 24h)

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

Auto-refreshes every 60 seconds · {{end}}Felhom Hub{{if .HasReports}}

{{end}} +
+
+ + + + diff --git a/hub/internal/web/templates/dashboard.html b/hub/internal/web/templates/dashboard.html index 0e66824..8e6b659 100644 --- a/hub/internal/web/templates/dashboard.html +++ b/hub/internal/web/templates/dashboard.html @@ -46,16 +46,16 @@ - {{if eq .OverallStatus "ok"}}OK{{else if eq .OverallStatus "warn"}}WARN{{else if eq .OverallStatus "disabled"}}PAUSED{{else}}DOWN{{end}} + {{if eq .OverallStatus "ok"}}OK{{else if eq .OverallStatus "warn"}}WARN{{else if eq .OverallStatus "disabled"}}PAUSED{{else if eq .OverallStatus "pending"}}PENDING{{else}}DOWN{{end}} - {{timeAgo .ReceivedAt}} - {{formatFloat .CPUPercent}}% - {{formatFloat .MemoryPercent}}% - {{.DiskSummary}} - {{.ContainerRunning}}/{{.ContainerTotal}} + {{if eq .OverallStatus "pending"}}—{{else}}{{timeAgo .ReceivedAt}}{{end}} + {{if eq .OverallStatus "pending"}}—{{else}}{{formatFloat .CPUPercent}}%{{end}} + {{if eq .OverallStatus "pending"}}—{{else}}{{formatFloat .MemoryPercent}}%{{end}} + {{if eq .OverallStatus "pending"}}—{{else}}{{.DiskSummary}}{{end}} + {{if eq .OverallStatus "pending"}}—{{else}}{{.ContainerRunning}}/{{.ContainerTotal}}{{end}} {{.BackupAge}} - {{.ControllerVersion}} + {{if .ControllerVersion}}{{.ControllerVersion}}{{else}}—{{end}} {{end}} diff --git a/hub/internal/web/templates/style.css b/hub/internal/web/templates/style.css index cf4e73a..d2a64f7 100644 --- a/hub/internal/web/templates/style.css +++ b/hub/internal/web/templates/style.css @@ -125,6 +125,35 @@ header h1 { .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); } .status-badge-disabled { background: #475569; color: #e2e8f0; } +.status-badge-pending { background: rgba(148, 163, 184, 0.15); color: #94a3b8; } +.status-badge-blocked { background: rgba(248, 113, 113, 0.15); color: #f87171; } + +/* Config badges */ +.config-badge { + display: inline-block; + padding: 0.15em 0.5em; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.03em; +} +.config-badge-managed { background: rgba(74, 222, 128, 0.15); color: #4ade80; } +.config-badge-manual { background: #475569; color: #e2e8f0; } +.config-badge-blocked { background: rgba(248, 113, 113, 0.2); color: #f87171; } + +/* Blocked banner */ +.flash-blocked { + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.3); + color: #fca5a5; + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +/* Blocked row in tables */ +.row-blocked { opacity: 0.5; } /* Cards */ .card {