feat: merge report-only customers into Customers page, rename tab

- Customers page now shows ALL customers: both pre-configured (managed)
  and report-only (manual) — merged from customer_configs + reports tables
- Renamed "Configurations" → "Customers" in navigation tabs
- Renamed "+ New Configuration" → "+ Add Customer"
- Status column with ok/warn/down badges, version column, managed/manual badge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 15:27:05 +01:00
parent b07132f617
commit cb425d8086
6 changed files with 115 additions and 24 deletions
+78 -3
View File
@@ -5,7 +5,9 @@ import (
"fmt"
"net/http"
"regexp"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
@@ -13,7 +15,19 @@ import (
var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`)
// handleConfigList shows all customer configurations.
// customerListEntry is a merged view of a customer from both configs and reports.
type customerListEntry struct {
CustomerID string
CustomerName string
Domain string
HasConfig bool
OverallStatus string // ok, warn, down, disabled, "" if no reports
ControllerVersion string
TimeSinceReport time.Duration
ConfigCreatedAt time.Time
}
// handleConfigList shows all customers (merged from configs + reports).
func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
configs, err := s.store.ListCustomerConfigs()
if err != nil {
@@ -22,12 +36,73 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
return
}
customers, err := s.store.GetCustomers()
if err != nil {
s.logger.Printf("[ERROR] Failed to list customers: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Build merged map keyed by customer_id
merged := make(map[string]*customerListEntry)
for _, cfg := range configs {
merged[cfg.CustomerID] = &customerListEntry{
CustomerID: cfg.CustomerID,
CustomerName: cfg.CustomerName,
Domain: cfg.Domain,
HasConfig: true,
ConfigCreatedAt: cfg.CreatedAt,
}
}
for _, c := range customers {
status := "ok"
if c.HealthStatus == "disabled" {
status = "disabled"
} else if c.TimeSinceReport > time.Hour {
status = "down"
} else if c.TimeSinceReport > 30*time.Minute || c.HealthStatus == "warn" {
status = "warn"
} else if c.HealthStatus == "fail" {
status = "down"
}
if entry, ok := merged[c.CustomerID]; ok {
// Config exists — enrich with report data
entry.OverallStatus = status
entry.ControllerVersion = c.ControllerVersion
entry.TimeSinceReport = c.TimeSinceReport
if entry.CustomerName == "" {
entry.CustomerName = c.CustomerName
}
} else {
// Report-only customer (no config)
merged[c.CustomerID] = &customerListEntry{
CustomerID: c.CustomerID,
CustomerName: c.CustomerName,
OverallStatus: status,
ControllerVersion: c.ControllerVersion,
TimeSinceReport: c.TimeSinceReport,
}
}
}
// Sort by customer_id
entries := make([]customerListEntry, 0, len(merged))
for _, e := range merged {
entries = append(entries, *e)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].CustomerID < entries[j].CustomerID
})
data := struct {
Configs []store.CustomerConfig
Customers []customerListEntry
ActiveNav string
Flash string
}{
Configs: configs,
Customers: entries,
ActiveNav: "configs",
Flash: r.URL.Query().Get("flash"),
}