Files
felhom.eu/hub/internal/web/server.go
T
admin e35b1ae0e6 hub v0.46.0: observability pass — per-box log pulls, bundle custody, 72h TTL + secret gate
log_bundle_requests + log_bundles store (gzip, newest-3, 72h TTL purged on the
60s sweep); SaveLogBundle secret gate fail-closed (blocked flag row, no payload;
REDACTED/checksums pass). Report ACK gains controller_log_requested + ingests
controller_log_tail; heartbeat envelope gains log_tail_requested + ingests
log_tail (consume-once on arrival; pre-0.83 agents stay visibly pending). Host
detail Diagnostics section: request buttons (controller/agent), state rows with
honest latency hints, View/Download endpoint. Red-proofs: gate disabled and
clear-on-arrival removed both FAIL their tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-11 16:57:47 +02:00

751 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"log"
"math"
"net/http"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"golang.org/x/crypto/bcrypt"
)
// Generic-package names + the artifact filename inside each version (used to resolve version lists +
// sha256 from Gitea for the Day-0 artifact manifest UI).
const (
pkgAgent = "felhom-agent"
fileAgent = "felhom-agent"
pkgGolden = "felhom-golden"
fileGolden = "golden.tar.zst"
)
// artifactChoice is one selectable artifact version + its Gitea-resolved sha256 (for the dropdown +
// the read-only sha display).
type artifactChoice struct {
Version string
SHA256 string
}
// hubSession holds per-session auth and CSRF data.
type hubSession struct {
expiresAt time.Time
csrfToken string
}
// Server handles the dashboard web UI.
type Server struct {
store *store.Store
passwordHash string
apiKey string // report API key — used for controller callbacks
version string
logger *log.Logger
templates *template.Template
staleThreshold time.Duration
versionChecker *VersionChecker
templateFetcher *TemplateFetcher
assetsMgr *assets.Manager
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
sessions map[string]*hubSession
sessionsMu sync.RWMutex
}
// New creates a new web server.
func New(store *store.Store, passwordHash, apiKey, version string, staleThreshold time.Duration, logger *log.Logger) *Server {
funcMap := template.FuncMap{
"timeAgo": timeAgo,
"timeAgoPtr": func(t *time.Time) string {
if t == nil {
return "—"
}
return timeAgo(*t)
},
"statusColor": statusColor,
"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)
},
"hubVersion": func() string { return version },
"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]
},
"memoryColor": memoryColor,
"accuracyClass": accuracyClass,
"gt": func(a, b int) bool { return a > b },
}
tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
return &Server{
store: store,
passwordHash: passwordHash,
apiKey: apiKey,
version: version,
logger: logger,
templates: tmpl,
staleThreshold: staleThreshold,
sessions: make(map[string]*hubSession),
}
}
// CleanupSessions removes expired sessions. Call with: go s.CleanupSessions(ctx).
func (s *Server) CleanupSessions(ctx context.Context) {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sessionsMu.Lock()
now := time.Now()
for t, sess := range s.sessions {
if now.After(sess.expiresAt) {
delete(s.sessions, t)
}
}
s.sessionsMu.Unlock()
}
}
}
// 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
}
// SetAssetManager sets the asset manager for the Configuration page (optional).
func (s *Server) SetAssetManager(am *assets.Manager) {
s.assetsMgr = am
}
// SetOffsiteProvisioner enables Hetzner offsite provisioning (optional). Without it, saving a config with
// offsite enabled returns an error (offsite not configured on this hub).
func (s *Server) SetOffsiteProvisioner(p *offsite.Provisioner) { s.offsite = p }
// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
// degrades to manual text entry.
func (s *Server) SetGiteaClient(c *gitea.Client) {
s.gitea = c
}
// artifactChoices resolves the currently-available versions of a generic package + each one's sha256
// from Gitea, newest first, for the Day-0 artifact dropdown. Returns nil (→ manual text-entry
// fallback) when no Gitea client is configured or Gitea is unreachable. A failed sha lookup for a
// single version drops just that version, not the whole list.
func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifactChoice {
if s.gitea == nil {
return nil
}
vers, err := s.gitea.ListVersions(ctx, pkg)
if err != nil {
s.logger.Printf("[WARN] artifact versions (%s): %v", pkg, err)
return nil
}
const maxChoices = 20
if len(vers) > maxChoices {
s.logger.Printf("[INFO] artifact versions (%s): showing newest %d of %d", pkg, maxChoices, len(vers))
vers = vers[:maxChoices]
}
out := make([]artifactChoice, 0, len(vers))
for _, v := range vers {
sha, err := s.gitea.FileSHA256(ctx, pkg, v, file)
if err != nil {
s.logger.Printf("[WARN] artifact sha (%s/%s): %v", pkg, v, err)
continue
}
out = append(out, artifactChoice{Version: v, SHA256: sha})
}
return out
}
// ServeHTTP routes web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// CSRF protection for all state-changing requests (web routes only).
// API routes (/api/v1/) are Bearer-token authenticated and exempt.
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
if path != "/login" && s.passwordHash != "" {
if !s.validateCSRF(r) {
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr)
http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden)
return
}
}
}
switch {
case path == "/":
s.handleDashboard(w, r)
case path == "/style.css":
s.handleCSS(w, r)
case path == "/static/chart.min.js":
w.Header().Set("Content-Type", "application/javascript")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(chartJS)
case strings.HasPrefix(path, "/static/fonts/"):
// Vendored brand fonts (design system v2) — embedded, no CDN.
name := strings.TrimPrefix(path, "/static/fonts/")
data, err := fontFS.ReadFile("static/fonts/" + name)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "font/woff2")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(data)
case path == "/configuration":
if r.Method == http.MethodPost {
s.handleConfigurationAction(w, r)
} else {
s.handleConfiguration(w, r)
}
case path == "/apps" || path == "/apps/":
s.handleApps(w, r)
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/reset-telemetry"):
appName := strings.TrimPrefix(path, "/apps/")
appName = strings.TrimSuffix(appName, "/reset-telemetry")
if r.Method == http.MethodPost {
s.handleResetAppTelemetry(w, r, appName)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/dismiss-issues"):
appName := strings.TrimPrefix(path, "/apps/")
appName = strings.TrimSuffix(appName, "/dismiss-issues")
if r.Method == http.MethodPost {
s.handleDismissAppIssues(w, r, appName)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/apps/"):
appName := strings.TrimPrefix(path, "/apps/")
s.handleAppDetail(w, r, appName)
// Offsite — read-only WG endpoint + peer registry (S2). Mutations stay on the admin API.
case path == "/offsite":
s.handleOffsite(w, r)
// Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
case path == "/hosts" || path == "/hosts/":
s.handleHostsList(w, r)
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/request-logs"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/request-logs")
if r.Method == http.MethodPost {
s.handleRequestLogBundle(w, r, hostID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/hosts/") && strings.Contains(path, "/log-bundles/"):
rest := strings.TrimPrefix(path, "/hosts/")
if i := strings.Index(rest, "/log-bundles/"); i > 0 {
s.handleLogBundleView(w, r, rest[:i], rest[i+len("/log-bundles/"):])
} else {
http.NotFound(w, r)
}
case strings.HasPrefix(path, "/hosts/"):
hostID := strings.TrimPrefix(path, "/hosts/")
s.handleHostDetail(w, r, hostID)
case path == "/login":
s.handleLogin(w, r)
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, "/geo/disable"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/geo/disable")
if r.Method == http.MethodPost {
s.handleGeoDisable(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/floor"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/floor")
if r.Method == http.MethodPost {
s.handleSetCustomerFloor(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/") && strings.HasSuffix(path, "/dr-recipe.json"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/dr-recipe.json")
s.handleDRRecipeDownload(w, r, customerID)
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/request-log-tail"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/request-log-tail")
if r.Method == http.MethodPost {
s.handleRequestLogTail(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.Contains(path, "/log-tail/"):
rest := strings.TrimPrefix(path, "/customers/")
parts := strings.SplitN(rest, "/log-tail/", 2)
if len(parts) != 2 {
http.NotFound(w, r)
return
}
s.handleLogTailView(w, r, parts[0], parts[1])
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)
}
// Global settings live under the Configuration tab (moved from Customers).
case path == "/configuration/global-floor/impact":
if r.Method == http.MethodGet {
s.handleGlobalFloorImpact(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case path == "/configuration/global-floor":
if r.Method == http.MethodPost {
s.handleSetGlobalFloor(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case path == "/configuration/artifacts":
if r.Method == http.MethodPost {
s.handleSetArtifacts(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
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, "/offsite-reissue"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/offsite-reissue")
if r.Method == http.MethodPost {
s.handleOffsiteReissue(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/pbsdr-reissue"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/pbsdr-reissue")
if r.Method == http.MethodPost {
s.handlePBSDRReissue(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/offsite-freeze"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/offsite-freeze")
if r.Method == http.MethodPost {
s.handleOffsiteFreeze(w, r, customerID, r.FormValue("unfreeze") != "1")
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
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 session or 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
}
// Always allow the login page through (GET and POST)
if r.URL.Path == "/login" {
next.ServeHTTP(w, r)
return
}
// Check session cookie (random token stored server-side)
if cookie, err := r.Cookie("hub_session"); err == nil {
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if ok && time.Now().Before(sess.expiresAt) {
next.ServeHTTP(w, r)
return
}
}
// Check basic auth (for programmatic/CLI access)
_, password, ok := r.BasicAuth()
if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
next.ServeHTTP(w, r)
return
}
// Redirect browsers to login page; send 401 for API-like requests
if r.Header.Get("Accept") == "application/json" || r.Header.Get("X-Requested-With") != "" {
w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
password := r.FormValue("password")
if s.passwordHash != "" && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
// Generate random session token
b := make([]byte, 32)
_, _ = rand.Read(b)
sessionToken := hex.EncodeToString(b)
// Generate CSRF token
cb := make([]byte, 32)
_, _ = rand.Read(cb)
csrfToken := hex.EncodeToString(cb)
s.sessionsMu.Lock()
s.sessions[sessionToken] = &hubSession{
expiresAt: time.Now().Add(7 * 24 * time.Hour),
csrfToken: csrfToken,
}
s.sessionsMu.Unlock()
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: "hub_session",
Value: sessionToken,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecure,
MaxAge: 86400 * 7,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Render login with error
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><p style="color:red">Hibás jelszó</p><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0083D8;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0083D8;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
}
// validateCSRF checks the CSRF token for a session-based request.
// Returns true if CSRF is valid or if no session cookie is present (Basic Auth path).
func (s *Server) validateCSRF(r *http.Request) bool {
cookie, err := r.Cookie("hub_session")
if err != nil {
// No session cookie — likely Basic Auth or programmatic access; skip CSRF
return true
}
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if !ok {
return false
}
submitted := r.FormValue("_csrf")
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
return submitted != "" && subtle.ConstantTimeCompare([]byte(submitted), []byte(sess.csrfToken)) == 1
}
// csrfToken returns the CSRF token for the current session.
func (s *Server) csrfToken(r *http.Request) string {
cookie, err := r.Cookie("hub_session")
if err != nil {
return ""
}
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if !ok {
return ""
}
return sess.csrfToken
}
// csrfField returns an HTML hidden input for embedding in forms.
func (s *Server) csrfField(r *http.Request) template.HTML {
tok := s.csrfToken(r)
return template.HTML(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(tok) + `">`)
}
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
EventCriticals int
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.EventCriticals = counts["critical"]
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)
}
}
// compareVersions delegates to THE hub comparator (internal/semver). Kept as a thin wrapper so the
// web package's many call sites are unchanged.
func compareVersions(a, b string) int { return semver.Compare(a, b) }
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)
}
// statusColor maps a status value to a design-system-v2 semantic token, consumed as a
// class suffix (status-dot-nominal, tag-nominal, …) — never as an inline color (D4).
// Exception-color principle: a healthy fleet is blue/neutral; amber/red only on deviation.
// ok -> nominal (blue — operating normally)
// warn, stale -> warn (amber — degraded / stale report)
// down, fail -> crit (red — outage)
// pending -> neutral (a not-yet-provisioned customer is a normal fleet state)
// disabled -> neutral (deliberately paused — not a deviation)
// blocked -> warn (an operator cut a customer off: intentional but attention-worthy)
func statusColor(status string) string {
switch status {
case "ok":
return "nominal"
case "warn", "stale":
return "warn"
case "down", "fail":
return "crit"
case "blocked":
return "warn"
case "disabled", "pending":
return "neutral"
default:
return "neutral"
}
}
// handleConfiguration renders the Configuration page.
func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
csrfToken := s.getCSRFToken(r)
assetCount := 0
assetLastSync := ""
if s.assetsMgr != nil {
assetCount = s.assetsMgr.FileCount()
if m := s.assetsMgr.GetManifest(); m != nil {
assetLastSync = m.Generated
}
}
ctx := r.Context()
data := map[string]interface{}{
"CSRFToken": csrfToken,
"CSRFField": s.csrfField(r),
"AssetCount": assetCount,
"AssetLastSync": assetLastSync,
"GlobalFloor": s.store.GetGlobalMinControllerVersion(),
"FloorRes": s.store.ResolveGlobalFloor(),
"Artifacts": s.store.GetArtifactManifest(),
"AgentChoices": s.artifactChoices(ctx, pkgAgent, fileAgent),
"GoldenChoices": s.artifactChoices(ctx, pkgGolden, fileGolden),
"Flash": r.URL.Query().Get("flash"),
}
if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil {
s.logger.Printf("[ERROR] configuration.html template: %v", err)
}
}
// handleConfigurationAction handles POST actions on the Configuration page.
func (s *Server) handleConfigurationAction(w http.ResponseWriter, r *http.Request) {
action := r.FormValue("action")
switch action {
case "refresh_assets":
if s.assetsMgr == nil {
http.Redirect(w, r, "/configuration?flash=assets_not_configured", http.StatusSeeOther)
return
}
if err := s.assetsMgr.ReSeed(); err != nil {
s.logger.Printf("[ERROR] Asset re-seed failed: %v", err)
http.Redirect(w, r, "/configuration?flash=assets_error", http.StatusSeeOther)
return
}
s.logger.Printf("[INFO] Manual asset re-seed completed")
http.Redirect(w, r, "/configuration?flash=assets_refreshed", http.StatusSeeOther)
default:
http.Redirect(w, r, "/configuration", http.StatusSeeOther)
}
}