Files
felhom.eu/hub/internal/web/server.go
T
admin 36a7d1c162 feat: add controller update trigger + version checker (v0.1.8)
Hub now tracks controller_url from reports, periodically checks the Gitea
registry for the latest controller image version, and shows a "Trigger Update"
button on the customer detail page that proxies to the controller's self-update
API endpoint using the shared API key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 18:16:38 +01:00

422 lines
12 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 (
"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
}
// 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)
},
}
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
}
// 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/"):
customerID := strings.TrimPrefix(path, "/customers/")
s.handleCustomerDetail(w, r, customerID)
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
}
type dashboardCustomer struct {
store.CustomerSummary
OverallStatus string // "ok", "warn", "down"
BackupAge string
}
var data []dashboardCustomer
for _, c := range customers {
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 = ""
}
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"`
}
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
}
}
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,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "customer.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":
return "#94a3b8" // gray
default:
return "#94a3b8" // gray
}
}
func statusIcon(status string) string {
return "●"
}