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 {
|
||||
|
||||
@@ -183,6 +183,85 @@
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Controller Update -->
|
||||
<section class="card">
|
||||
<h2>Controller Update</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Current version</span>
|
||||
<span class="value">v{{.Customer.ControllerVersion}}</span>
|
||||
</div>
|
||||
{{if .LatestVersion}}
|
||||
<div class="info-item">
|
||||
<span class="label">Latest version</span>
|
||||
<span class="value">
|
||||
v{{.LatestVersion}}
|
||||
{{if .UpdateAvailable}}
|
||||
<span style="color: #4ade80; margin-left: 0.3em;">● update available</span>
|
||||
{{else}}
|
||||
<span style="color: #94a3b8; margin-left: 0.3em;">— up to date</span>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .ControllerURL}}
|
||||
<div class="info-item">
|
||||
<span class="label">Controller URL</span>
|
||||
<span class="value"><a href="{{.ControllerURL}}" target="_blank" style="color: #60a5fa;">{{.ControllerURL}}</a></span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if and .ControllerURL .UpdateAvailable}}
|
||||
<div style="margin-top: 0.75em;">
|
||||
<button class="btn" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.Customer.CustomerID}}')">
|
||||
Trigger Update
|
||||
</button>
|
||||
<span id="update-msg" style="margin-left: 0.5em; display: none;"></span>
|
||||
</div>
|
||||
{{else if and .ControllerURL (not .LatestVersion)}}
|
||||
<div style="margin-top: 0.75em;">
|
||||
<button class="btn" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.Customer.CustomerID}}')">
|
||||
Trigger Update
|
||||
</button>
|
||||
<span id="update-msg" style="margin-left: 0.5em; display: none;"></span>
|
||||
<p style="color: #94a3b8; font-size: 0.85em; margin-top: 0.3em;">Registry check not configured — cannot verify if update is available</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function triggerControllerUpdate(customerID) {
|
||||
if (!confirm('Trigger self-update on this controller?\n\nThe controller will be briefly unavailable during restart.')) return;
|
||||
var btn = document.getElementById('btn-trigger-update');
|
||||
var msg = document.getElementById('update-msg');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Triggering...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/customers/' + customerID + '/trigger-update', {method: 'POST'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
msg.textContent = 'Update triggered — controller restarting';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#4ade80';
|
||||
} else {
|
||||
msg.textContent = data.error || 'Failed';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Trigger Update';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msg.textContent = 'Connection error';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Trigger Update';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="card">
|
||||
<h2>Notifications</h2>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VersionChecker periodically queries the Gitea Docker Registry V2 API
|
||||
// for the latest controller image version tag.
|
||||
type VersionChecker struct {
|
||||
image string // e.g., "gitea.dooplex.hu/admin/felhom-controller"
|
||||
username string
|
||||
token string
|
||||
checkInterval time.Duration
|
||||
logger *log.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
latestVersion string
|
||||
lastCheck time.Time
|
||||
lastError string
|
||||
}
|
||||
|
||||
// NewVersionChecker creates a new VersionChecker.
|
||||
func NewVersionChecker(image, username, token string, checkInterval time.Duration, logger *log.Logger) *VersionChecker {
|
||||
return &VersionChecker{
|
||||
image: image,
|
||||
username: username,
|
||||
token: token,
|
||||
checkInterval: checkInterval,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the periodic version check loop. Call in a goroutine.
|
||||
// It checks immediately on start, then every checkInterval.
|
||||
func (vc *VersionChecker) Run(ctx context.Context) {
|
||||
vc.check()
|
||||
ticker := time.NewTicker(vc.checkInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
vc.check()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (vc *VersionChecker) check() {
|
||||
latest, err := vc.queryRegistry()
|
||||
vc.mu.Lock()
|
||||
defer vc.mu.Unlock()
|
||||
vc.lastCheck = time.Now()
|
||||
if err != nil {
|
||||
vc.lastError = err.Error()
|
||||
vc.logger.Printf("[WARN] Registry version check failed: %v", err)
|
||||
return
|
||||
}
|
||||
vc.lastError = ""
|
||||
vc.latestVersion = latest
|
||||
vc.logger.Printf("[DEBUG] Registry version check: latest = %s", latest)
|
||||
}
|
||||
|
||||
// LatestVersion returns the cached latest version string (e.g., "0.16.1"), or "" if unknown.
|
||||
func (vc *VersionChecker) LatestVersion() string {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.latestVersion
|
||||
}
|
||||
|
||||
// queryRegistry queries the Gitea Docker Registry V2 API for available tags
|
||||
// and returns the highest valid semver tag.
|
||||
func (vc *VersionChecker) queryRegistry() (string, error) {
|
||||
// Extract "owner/repo" from image reference
|
||||
// e.g., "gitea.dooplex.hu/admin/felhom-controller" → "admin/felhom-controller"
|
||||
imagePath := vc.image
|
||||
if parts := strings.SplitN(vc.image, "/", 2); len(parts) == 2 {
|
||||
imagePath = parts[1]
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", imagePath)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(vc.username, vc.token)
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
return "", fmt.Errorf("authentication failed (401)")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var tagsResp struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil {
|
||||
return "", fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
// Find the highest valid semver tag
|
||||
var bestMajor, bestMinor, bestPatch int
|
||||
found := false
|
||||
for _, tag := range tagsResp.Tags {
|
||||
tag = strings.TrimPrefix(tag, "v")
|
||||
parts := strings.SplitN(tag, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
major, e1 := strconv.Atoi(parts[0])
|
||||
minor, e2 := strconv.Atoi(parts[1])
|
||||
patch, e3 := strconv.Atoi(parts[2])
|
||||
if e1 != nil || e2 != nil || e3 != nil {
|
||||
continue
|
||||
}
|
||||
if !found || major > bestMajor ||
|
||||
(major == bestMajor && minor > bestMinor) ||
|
||||
(major == bestMajor && minor == bestMinor && patch > bestPatch) {
|
||||
bestMajor, bestMinor, bestPatch = major, minor, patch
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return "", fmt.Errorf("no valid semver tags found")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d.%d.%d", bestMajor, bestMinor, bestPatch), nil
|
||||
}
|
||||
Reference in New Issue
Block a user