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 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 15:57:39 +01:00
parent cb425d8086
commit 42e0617a6c
9 changed files with 1017 additions and 168 deletions
+60 -97
View File
@@ -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