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>
This commit is contained in:
+139
-5
@@ -4,9 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,15 +18,17 @@ import (
|
||||
|
||||
// Server handles the dashboard web UI.
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
passwordHash string
|
||||
logger *log.Logger
|
||||
templates *template.Template
|
||||
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 string, staleThreshold time.Duration, logger *log.Logger) *Server {
|
||||
func New(store *store.Store, passwordHash, apiKey string, staleThreshold time.Duration, logger *log.Logger) *Server {
|
||||
funcMap := template.FuncMap{
|
||||
"timeAgo": timeAgo,
|
||||
"statusColor": statusColor,
|
||||
@@ -42,12 +46,18 @@ func New(store *store.Store, passwordHash string, staleThreshold time.Duration,
|
||||
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
|
||||
@@ -59,6 +69,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -203,6 +221,29 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
|
||||
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"
|
||||
@@ -230,6 +271,9 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
|
||||
RecentNotifications: recentNotifs,
|
||||
InfraBackup: infraMeta,
|
||||
InfraBackupAge: infraBackupAge,
|
||||
ControllerURL: controllerURL,
|
||||
LatestVersion: latestVersion,
|
||||
UpdateAvailable: updateAvailable,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -238,6 +282,96 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user