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
{{.CustomerID}}{{.ControllerVersion}}{{else}}—{{end}}Last report: {{timeAgo .Customer.ReceivedAt}} · Controller v{{.Customer.ControllerVersion}}
+ {{else}} +No reports received yet
+ {{end}} +{{.CustomerID}}
+ | Name | +State | +CPU | +Memory | +
|---|---|---|---|
| {{index . "name"}} | +{{index . "state"}} | +{{printf "%.1f" (index . "cpu_percent")}}% | +{{printf "%.0f" (index . "memory_mb")}} MB | +
No infra backup received yet
+ {{end}} +Reporting has been disabled on this node
+Enable it in the controller's controller.yaml: hub.enabled: true
Customer is blocked
+ {{else}} + {{with .Report.health}} ++ Status: {{index . "status"}} +
+ {{$issues := index . "issues"}} + {{if $issues}} +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.
+{{.Config.RetrievalPassword}}
+
+ {{.Config.APIKey}}
+
+ Use one of these methods to configure a customer node:
+ +sudo ./docker-setup.sh --hub-customer {{.CustomerID}} --hub-password {{.Config.RetrievalPassword}}
+
+ curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml
+
+ Loading preview...
+Registry check not configured — cannot verify if update is available
+ {{end}} +| Time | +Event | +Status | +Message | +
|---|---|---|---|
| {{.CreatedAt.Format "Jan 02 15:04"}} | +{{.EventType}} | +{{.Status}} | +{{.Message}} | +
| Time | +Status | +CPU | +Memory | +
|---|---|---|---|
| {{.ReceivedAt.Format "Jan 02 15:04"}} | +{{.HealthStatus}} | +{{formatFloat .CPUPercent}}% | +{{formatFloat .MemoryPercent}}% | +
{{.ControllerVersion}}{{.ControllerVersion}}{{else}}—{{end}}