11428659d1
Replace broken SHA256 hash comparison with value-based YAML comparison.
Add "Show Diff" button showing per-key differences in a color-coded table.
Add "Pull Config" to import controller's current config into the Hub.
New endpoints: GET /customers/{id}/config-diff, POST /customers/{id}/pull-config.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
462 lines
14 KiB
Go
462 lines
14 KiB
Go
package web
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"io"
|
||
"log"
|
||
"math"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
// Server handles the dashboard web UI.
|
||
type Server struct {
|
||
store *store.Store
|
||
passwordHash string
|
||
apiKey string // report API key — used for controller callbacks
|
||
logger *log.Logger
|
||
templates *template.Template
|
||
staleThreshold time.Duration
|
||
versionChecker *VersionChecker
|
||
templateFetcher *TemplateFetcher
|
||
}
|
||
|
||
// New creates a new web server.
|
||
func New(store *store.Store, passwordHash, apiKey string, staleThreshold time.Duration, logger *log.Logger) *Server {
|
||
funcMap := template.FuncMap{
|
||
"timeAgo": timeAgo,
|
||
"statusColor": statusColor,
|
||
"statusIcon": statusIcon,
|
||
"formatFloat": func(f float64) string { return fmt.Sprintf("%.0f", f) },
|
||
"joinStrings": func(s []string, sep string) string { return strings.Join(s, sep) },
|
||
"json": func(v interface{}) template.JS {
|
||
b, _ := json.Marshal(v)
|
||
return template.JS(b)
|
||
},
|
||
"add": func(a, b int) int { return a + b },
|
||
"mapGet": func(m map[string]int, key string) int {
|
||
if m == nil {
|
||
return 0
|
||
}
|
||
return m[key]
|
||
},
|
||
}
|
||
|
||
tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
|
||
|
||
return &Server{
|
||
store: store,
|
||
passwordHash: passwordHash,
|
||
apiKey: apiKey,
|
||
logger: logger,
|
||
templates: tmpl,
|
||
staleThreshold: staleThreshold,
|
||
}
|
||
}
|
||
|
||
// SetVersionChecker sets the version checker (optional, may be nil if no registry credentials).
|
||
func (s *Server) SetVersionChecker(vc *VersionChecker) {
|
||
s.versionChecker = vc
|
||
}
|
||
|
||
// SetTemplateFetcher sets the template fetcher for config generation (optional).
|
||
func (s *Server) SetTemplateFetcher(tf *TemplateFetcher) {
|
||
s.templateFetcher = tf
|
||
}
|
||
|
||
// ServeHTTP routes web requests.
|
||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
path := r.URL.Path
|
||
|
||
switch {
|
||
case path == "/":
|
||
s.handleDashboard(w, r)
|
||
case path == "/style.css":
|
||
s.handleCSS(w, r)
|
||
case path == "/login":
|
||
s.handleLogin(w, r)
|
||
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/trigger-update"):
|
||
customerID := strings.TrimPrefix(path, "/customers/")
|
||
customerID = strings.TrimSuffix(customerID, "/trigger-update")
|
||
if r.Method == http.MethodPost {
|
||
s.handleTriggerUpdate(w, r, customerID)
|
||
} 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, "/pull-config"):
|
||
customerID := strings.TrimPrefix(path, "/customers/")
|
||
customerID = strings.TrimSuffix(customerID, "/pull-config")
|
||
if r.Method == http.MethodPost {
|
||
s.handlePullConfig(w, r, customerID)
|
||
} else {
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/config-diff"):
|
||
customerID := strings.TrimPrefix(path, "/customers/")
|
||
customerID = strings.TrimSuffix(customerID, "/config-diff")
|
||
if r.Method == http.MethodGet {
|
||
s.handleConfigDiff(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.handleCustomerUnified(w, r, customerID)
|
||
// Config management routes — exact matches first, then prefix matches
|
||
case path == "/configs":
|
||
s.handleConfigList(w, r)
|
||
case path == "/configs/new":
|
||
if r.Method == http.MethodPost {
|
||
s.handleConfigCreate(w, r)
|
||
} else {
|
||
s.handleConfigNewForm(w, r)
|
||
}
|
||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"):
|
||
customerID := strings.TrimPrefix(path, "/configs/")
|
||
customerID = strings.TrimSuffix(customerID, "/delete")
|
||
if r.Method == http.MethodPost {
|
||
s.handleConfigDelete(w, r, customerID)
|
||
} else {
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/edit"):
|
||
customerID := strings.TrimPrefix(path, "/configs/")
|
||
customerID = strings.TrimSuffix(customerID, "/edit")
|
||
if r.Method == http.MethodPost {
|
||
s.handleConfigUpdate(w, r, customerID)
|
||
} else {
|
||
s.handleConfigEditForm(w, r, customerID)
|
||
}
|
||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/preview"):
|
||
customerID := strings.TrimPrefix(path, "/configs/")
|
||
customerID = strings.TrimSuffix(customerID, "/preview")
|
||
s.handleConfigPreview(w, r, customerID)
|
||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/regen-password"):
|
||
customerID := strings.TrimPrefix(path, "/configs/")
|
||
customerID = strings.TrimSuffix(customerID, "/regen-password")
|
||
if r.Method == http.MethodPost {
|
||
s.handleConfigRegenPassword(w, r, customerID)
|
||
} else {
|
||
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/")
|
||
http.Redirect(w, r, "/customers/"+customerID, http.StatusSeeOther)
|
||
default:
|
||
http.NotFound(w, r)
|
||
}
|
||
}
|
||
|
||
// RequireAuth wraps a handler with basic authentication.
|
||
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// Skip auth if no password configured
|
||
if s.passwordHash == "" {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
// Check session cookie
|
||
if cookie, err := r.Cookie("hub_session"); err == nil && cookie.Value == "authenticated" {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
// Check basic auth
|
||
_, password, ok := r.BasicAuth()
|
||
if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
// Show login page for browser requests
|
||
if r.URL.Path == "/login" && r.Method == http.MethodPost {
|
||
s.handleLogin(w, r)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`)
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == http.MethodPost {
|
||
password := r.FormValue("password")
|
||
if bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: "hub_session",
|
||
Value: "authenticated",
|
||
Path: "/",
|
||
HttpOnly: true,
|
||
MaxAge: 86400 * 7, // 7 days
|
||
})
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
return
|
||
}
|
||
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusOK)
|
||
w.Write([]byte(`<html><body><form method="post"><input type="password" name="password"><button>Login</button></form></body></html>`))
|
||
}
|
||
|
||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||
customers, err := s.store.GetCustomers()
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] Dashboard: %v", err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
configs, _ := s.store.ListCustomerConfigs()
|
||
|
||
type dashboardCustomer struct {
|
||
store.CustomerSummary
|
||
OverallStatus string // "ok", "warn", "down", "pending"
|
||
BackupAge string
|
||
EventErrors int
|
||
EventWarnings int
|
||
}
|
||
|
||
// 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
|
||
if c.HealthStatus == "disabled" {
|
||
dc.OverallStatus = "disabled"
|
||
} else if c.TimeSinceReport > time.Hour {
|
||
dc.OverallStatus = "down"
|
||
} else if c.TimeSinceReport > 30*time.Minute || c.HealthStatus == "warn" {
|
||
dc.OverallStatus = "warn"
|
||
} else if c.HealthStatus == "fail" {
|
||
dc.OverallStatus = "down"
|
||
} else {
|
||
dc.OverallStatus = "ok"
|
||
}
|
||
|
||
// Backup age
|
||
if c.BackupLastSnapshot != nil {
|
||
dc.BackupAge = timeAgo(*c.BackupLastSnapshot)
|
||
} else {
|
||
dc.BackupAge = "–"
|
||
}
|
||
|
||
// Event counts (last 24h)
|
||
if counts, err := s.store.CountEventsBySeverity(c.CustomerID, time.Now().Add(-24*time.Hour)); err == nil {
|
||
dc.EventErrors = counts["error"]
|
||
dc.EventWarnings = counts["warning"]
|
||
}
|
||
|
||
data = append(data, dc)
|
||
}
|
||
|
||
// Add config-only customers (no reports yet) as "pending"
|
||
for _, cfg := range configs {
|
||
if seen[cfg.CustomerID] || cfg.Status == "blocked" {
|
||
continue
|
||
}
|
||
dc := dashboardCustomer{
|
||
CustomerSummary: store.CustomerSummary{
|
||
CustomerID: cfg.CustomerID,
|
||
CustomerName: cfg.CustomerName,
|
||
},
|
||
OverallStatus: "pending",
|
||
BackupAge: "–",
|
||
}
|
||
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) handleTriggerUpdate(w http.ResponseWriter, r *http.Request, customerID string) {
|
||
customer, err := s.store.GetCustomer(customerID)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] Trigger update — get customer %s: %v", customerID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if customer == nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
// Get controller URL — from denormalized field or report JSON fallback
|
||
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 next report"}`))
|
||
return
|
||
}
|
||
|
||
if s.apiKey == "" {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
w.Write([]byte(`{"ok":false,"error":"API key not configured"}`))
|
||
return
|
||
}
|
||
|
||
// POST to controller's self-update endpoint
|
||
updateURL := controllerURL + "/api/selfupdate/update"
|
||
req, err := http.NewRequest("POST", updateURL, nil)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] Trigger update — create request for %s: %v", updateURL, err)
|
||
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)
|
||
|
||
client := &http.Client{Timeout: 30 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] Trigger update — request to %s failed: %v", updateURL, 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()
|
||
|
||
// Forward the controller's response
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||
s.logger.Printf("[INFO] Trigger update for %s — controller responded %d: %s", customerID, resp.StatusCode, string(body))
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(resp.StatusCode)
|
||
w.Write(body)
|
||
}
|
||
|
||
// compareVersions returns >0 if a > b, 0 if equal, <0 if a < b.
|
||
// Accepts "X.Y.Z" format. Returns 0 on parse error.
|
||
func compareVersions(a, b string) int {
|
||
a = strings.TrimPrefix(a, "v")
|
||
b = strings.TrimPrefix(b, "v")
|
||
aParts := strings.SplitN(a, ".", 3)
|
||
bParts := strings.SplitN(b, ".", 3)
|
||
if len(aParts) != 3 || len(bParts) != 3 {
|
||
return 0
|
||
}
|
||
for i := 0; i < 3; i++ {
|
||
ai, e1 := strconv.Atoi(aParts[i])
|
||
bi, e2 := strconv.Atoi(bParts[i])
|
||
if e1 != nil || e2 != nil {
|
||
return 0
|
||
}
|
||
if ai != bi {
|
||
return ai - bi
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
|
||
data, err := templateFS.ReadFile("templates/style.css")
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/css")
|
||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||
w.Write(data)
|
||
}
|
||
|
||
func timeAgo(t time.Time) string {
|
||
d := time.Since(t)
|
||
|
||
if d < time.Minute {
|
||
return "just now"
|
||
}
|
||
if d < time.Hour {
|
||
m := int(math.Round(d.Minutes()))
|
||
return fmt.Sprintf("%d min ago", m)
|
||
}
|
||
if d < 24*time.Hour {
|
||
h := int(math.Round(d.Hours()))
|
||
return fmt.Sprintf("%dh ago", h)
|
||
}
|
||
days := int(d.Hours() / 24)
|
||
return fmt.Sprintf("%dd ago", days)
|
||
}
|
||
|
||
func statusColor(status string) string {
|
||
switch status {
|
||
case "ok":
|
||
return "#4ade80" // green
|
||
case "warn":
|
||
return "#facc15" // yellow
|
||
case "down", "fail":
|
||
return "#f87171" // red
|
||
case "disabled", "pending", "blocked":
|
||
return "#94a3b8" // gray
|
||
default:
|
||
return "#94a3b8" // gray
|
||
}
|
||
}
|
||
|
||
func statusIcon(status string) string {
|
||
return "●"
|
||
}
|