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:
+60
-97
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user