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:
2026-02-19 18:16:38 +01:00
parent d8e1ec44d7
commit 36a7d1c162
6 changed files with 444 additions and 35 deletions
+29 -2
View File
@@ -42,6 +42,12 @@ type Config struct {
Alerting struct { Alerting struct {
StaleThreshold string `yaml:"stale_threshold"` StaleThreshold string `yaml:"stale_threshold"`
} `yaml:"alerting"` } `yaml:"alerting"`
Registry struct {
Image string `yaml:"image"`
Username string `yaml:"username"`
Token string `yaml:"token"`
CheckInterval string `yaml:"check_interval"`
} `yaml:"registry"`
Server struct { Server struct {
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
DataDir string `yaml:"data_dir"` DataDir string `yaml:"data_dir"`
@@ -84,7 +90,7 @@ func main() {
// Initialize handlers // Initialize handlers
apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, logger) apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, logger)
webServer := web.New(dataStore, cfg.Auth.PasswordHash, staleThreshold, logger) webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, staleThreshold, logger)
// Build HTTP mux // Build HTTP mux
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -114,10 +120,25 @@ func main() {
IdleTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second,
} }
// Background: daily prune // Background: daily prune + version checker
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// Initialize version checker for controller image registry
var versionChecker *web.VersionChecker
if cfg.Registry.Username != "" && cfg.Registry.Token != "" {
checkInterval, err := time.ParseDuration(cfg.Registry.CheckInterval)
if err != nil {
checkInterval = 6 * time.Hour
}
versionChecker = web.NewVersionChecker(cfg.Registry.Image, cfg.Registry.Username, cfg.Registry.Token, checkInterval, logger)
go versionChecker.Run(ctx)
logger.Printf("[INFO] Registry version checker started (every %s)", cfg.Registry.CheckInterval)
} else {
logger.Printf("[INFO] Registry version checker disabled (no credentials configured)")
}
webServer.SetVersionChecker(versionChecker)
if cfg.Retention.MaxDays > 0 { if cfg.Retention.MaxDays > 0 {
go pruneLoop(ctx, dataStore, cfg.Retention.MaxDays, logger) go pruneLoop(ctx, dataStore, cfg.Retention.MaxDays, logger)
} }
@@ -184,6 +205,12 @@ func loadConfig(path string, logger *log.Logger) *Config {
if cfg.Notifications.FromEmail == "" { if cfg.Notifications.FromEmail == "" {
cfg.Notifications.FromEmail = "monitoring@felhom.eu" cfg.Notifications.FromEmail = "monitoring@felhom.eu"
} }
if cfg.Registry.Image == "" {
cfg.Registry.Image = "gitea.dooplex.hu/admin/felhom-controller"
}
if cfg.Registry.CheckInterval == "" {
cfg.Registry.CheckInterval = "6h"
}
return cfg return cfg
} }
+20 -18
View File
@@ -110,30 +110,32 @@ func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) {
} }
type customerJSON struct { type customerJSON struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ControllerVersion string `json:"controller_version"` ControllerVersion string `json:"controller_version"`
HealthStatus string `json:"health_status"` ControllerURL string `json:"controller_url,omitempty"`
LastSeen time.Time `json:"last_seen"` HealthStatus string `json:"health_status"`
CPUPercent float64 `json:"cpu_percent"` LastSeen time.Time `json:"last_seen"`
MemoryPercent float64 `json:"memory_percent"` CPUPercent float64 `json:"cpu_percent"`
ContainerTotal int `json:"container_total"` MemoryPercent float64 `json:"memory_percent"`
ContainerRunning int `json:"container_running"` ContainerTotal int `json:"container_total"`
ContainerRunning int `json:"container_running"`
BackupLastSnapshot *time.Time `json:"backup_last_snapshot"` BackupLastSnapshot *time.Time `json:"backup_last_snapshot"`
} }
result := make([]customerJSON, 0, len(customers)) result := make([]customerJSON, 0, len(customers))
for _, c := range customers { for _, c := range customers {
result = append(result, customerJSON{ result = append(result, customerJSON{
ID: c.CustomerID, ID: c.CustomerID,
Name: c.CustomerName, Name: c.CustomerName,
ControllerVersion: c.ControllerVersion, ControllerVersion: c.ControllerVersion,
HealthStatus: c.HealthStatus, ControllerURL: c.ControllerURL,
LastSeen: c.ReceivedAt, HealthStatus: c.HealthStatus,
CPUPercent: c.CPUPercent, LastSeen: c.ReceivedAt,
MemoryPercent: c.MemoryPercent, CPUPercent: c.CPUPercent,
ContainerTotal: c.ContainerTotal, MemoryPercent: c.MemoryPercent,
ContainerRunning: c.ContainerRunning, ContainerTotal: c.ContainerTotal,
ContainerRunning: c.ContainerRunning,
BackupLastSnapshot: c.BackupLastSnapshot, BackupLastSnapshot: c.BackupLastSnapshot,
}) })
} }
+31 -10
View File
@@ -29,6 +29,7 @@ type CustomerSummary struct {
ContainerRunning int ContainerRunning int
BackupLastSnapshot *time.Time BackupLastSnapshot *time.Time
ReportJSON string ReportJSON string
ControllerURL string
// Computed fields (not stored) // Computed fields (not stored)
TimeSinceReport time.Duration TimeSinceReport time.Duration
@@ -98,7 +99,14 @@ func (s *Store) migrate() error {
updated_at DATETIME NOT NULL DEFAULT (datetime('now')) updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
); );
`) `)
return err if err != nil {
return err
}
// v0.1.8: add controller_url column (idempotent — ignore error if already exists)
s.db.Exec("ALTER TABLE reports ADD COLUMN controller_url TEXT")
return nil
} }
// NotificationPrefs holds per-customer notification preferences. // NotificationPrefs holds per-customer notification preferences.
@@ -201,6 +209,7 @@ func (s *Store) SaveReport(customerID string, reportJSON []byte) error {
// Parse denormalized fields from the JSON // Parse denormalized fields from the JSON
var parsed struct { var parsed struct {
ControllerVersion string `json:"controller_version"` ControllerVersion string `json:"controller_version"`
ControllerURL string `json:"controller_url"`
System struct { System struct {
CPUPercent float64 `json:"cpu_percent"` CPUPercent float64 `json:"cpu_percent"`
MemoryPercent float64 `json:"memory_percent"` MemoryPercent float64 `json:"memory_percent"`
@@ -229,13 +238,13 @@ func (s *Store) SaveReport(customerID string, reportJSON []byte) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT INTO reports (customer_id, report_json, health_status, cpu_percent, INSERT INTO reports (customer_id, report_json, health_status, cpu_percent,
memory_percent, container_total, container_running, memory_percent, container_total, container_running,
backup_last_snapshot, controller_version) backup_last_snapshot, controller_version, controller_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
customerID, string(reportJSON), customerID, string(reportJSON),
parsed.Health.Status, parsed.System.CPUPercent, parsed.Health.Status, parsed.System.CPUPercent,
parsed.System.MemoryPercent, parsed.Containers.Total, parsed.System.MemoryPercent, parsed.Containers.Total,
parsed.Containers.Running, backupSnapshot, parsed.Containers.Running, backupSnapshot,
parsed.ControllerVersion, parsed.ControllerVersion, parsed.ControllerURL,
) )
return err return err
} }
@@ -246,7 +255,7 @@ func (s *Store) GetCustomers() ([]CustomerSummary, error) {
SELECT r.customer_id, r.received_at, r.report_json, SELECT r.customer_id, r.received_at, r.report_json,
r.health_status, r.cpu_percent, r.memory_percent, r.health_status, r.cpu_percent, r.memory_percent,
r.container_total, r.container_running, r.container_total, r.container_running,
r.backup_last_snapshot, r.controller_version r.backup_last_snapshot, r.controller_version, r.controller_url
FROM reports r FROM reports r
INNER JOIN ( INNER JOIN (
SELECT customer_id, MAX(received_at) as max_time SELECT customer_id, MAX(received_at) as max_time
@@ -265,11 +274,12 @@ func (s *Store) GetCustomers() ([]CustomerSummary, error) {
var c CustomerSummary var c CustomerSummary
var receivedAt string var receivedAt string
var backupSnapshot sql.NullString var backupSnapshot sql.NullString
var controllerURL sql.NullString
if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON, if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON,
&c.HealthStatus, &c.CPUPercent, &c.MemoryPercent, &c.HealthStatus, &c.CPUPercent, &c.MemoryPercent,
&c.ContainerTotal, &c.ContainerRunning, &c.ContainerTotal, &c.ContainerRunning,
&backupSnapshot, &c.ControllerVersion); err != nil { &backupSnapshot, &c.ControllerVersion, &controllerURL); err != nil {
return nil, err return nil, err
} }
@@ -282,6 +292,9 @@ func (s *Store) GetCustomers() ([]CustomerSummary, error) {
c.BackupLastSnapshot = &t c.BackupLastSnapshot = &t
} }
} }
if controllerURL.Valid {
c.ControllerURL = controllerURL.String
}
// Parse customer_name from JSON // Parse customer_name from JSON
var report struct { var report struct {
@@ -306,7 +319,7 @@ func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) {
SELECT customer_id, received_at, report_json, SELECT customer_id, received_at, report_json,
health_status, cpu_percent, memory_percent, health_status, cpu_percent, memory_percent,
container_total, container_running, container_total, container_running,
backup_last_snapshot, controller_version backup_last_snapshot, controller_version, controller_url
FROM reports FROM reports
WHERE customer_id = ? WHERE customer_id = ?
ORDER BY received_at DESC ORDER BY received_at DESC
@@ -315,11 +328,12 @@ func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) {
var c CustomerSummary var c CustomerSummary
var receivedAt string var receivedAt string
var backupSnapshot sql.NullString var backupSnapshot sql.NullString
var controllerURL sql.NullString
if err := row.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON, if err := row.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON,
&c.HealthStatus, &c.CPUPercent, &c.MemoryPercent, &c.HealthStatus, &c.CPUPercent, &c.MemoryPercent,
&c.ContainerTotal, &c.ContainerRunning, &c.ContainerTotal, &c.ContainerRunning,
&backupSnapshot, &c.ControllerVersion); err != nil { &backupSnapshot, &c.ControllerVersion, &controllerURL); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -335,6 +349,9 @@ func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) {
c.BackupLastSnapshot = &t c.BackupLastSnapshot = &t
} }
} }
if controllerURL.Valid {
c.ControllerURL = controllerURL.String
}
var report struct { var report struct {
CustomerName string `json:"customer_name"` CustomerName string `json:"customer_name"`
@@ -355,7 +372,7 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu
SELECT customer_id, received_at, report_json, SELECT customer_id, received_at, report_json,
health_status, cpu_percent, memory_percent, health_status, cpu_percent, memory_percent,
container_total, container_running, container_total, container_running,
backup_last_snapshot, controller_version backup_last_snapshot, controller_version, controller_url
FROM reports FROM reports
WHERE customer_id = ? AND received_at >= ? WHERE customer_id = ? AND received_at >= ?
ORDER BY received_at DESC`, customerID, cutoff) ORDER BY received_at DESC`, customerID, cutoff)
@@ -369,11 +386,12 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu
var c CustomerSummary var c CustomerSummary
var receivedAt string var receivedAt string
var backupSnapshot sql.NullString var backupSnapshot sql.NullString
var controllerURL sql.NullString
if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON, if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON,
&c.HealthStatus, &c.CPUPercent, &c.MemoryPercent, &c.HealthStatus, &c.CPUPercent, &c.MemoryPercent,
&c.ContainerTotal, &c.ContainerRunning, &c.ContainerTotal, &c.ContainerRunning,
&backupSnapshot, &c.ControllerVersion); err != nil { &backupSnapshot, &c.ControllerVersion, &controllerURL); err != nil {
return nil, err return nil, err
} }
@@ -386,6 +404,9 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu
c.BackupLastSnapshot = &t c.BackupLastSnapshot = &t
} }
} }
if controllerURL.Valid {
c.ControllerURL = controllerURL.String
}
history = append(history, c) history = append(history, c)
} }
+139 -5
View File
@@ -4,9 +4,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"io"
"log" "log"
"math" "math"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -16,15 +18,17 @@ import (
// Server handles the dashboard web UI. // Server handles the dashboard web UI.
type Server struct { type Server struct {
store *store.Store store *store.Store
passwordHash string passwordHash string
logger *log.Logger apiKey string // report API key — used for controller callbacks
templates *template.Template logger *log.Logger
templates *template.Template
staleThreshold time.Duration staleThreshold time.Duration
versionChecker *VersionChecker
} }
// New creates a new web server. // 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{ funcMap := template.FuncMap{
"timeAgo": timeAgo, "timeAgo": timeAgo,
"statusColor": statusColor, "statusColor": statusColor,
@@ -42,12 +46,18 @@ func New(store *store.Store, passwordHash string, staleThreshold time.Duration,
return &Server{ return &Server{
store: store, store: store,
passwordHash: passwordHash, passwordHash: passwordHash,
apiKey: apiKey,
logger: logger, logger: logger,
templates: tmpl, templates: tmpl,
staleThreshold: staleThreshold, 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. // ServeHTTP routes web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
@@ -59,6 +69,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.handleCSS(w, r) s.handleCSS(w, r)
case path == "/login": case path == "/login":
s.handleLogin(w, r) 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/"): case strings.HasPrefix(path, "/customers/"):
customerID := strings.TrimPrefix(path, "/customers/") customerID := strings.TrimPrefix(path, "/customers/")
s.handleCustomerDetail(w, r, customerID) s.handleCustomerDetail(w, r, customerID)
@@ -203,6 +221,29 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
RecentNotifications []store.NotificationLogEntry RecentNotifications []store.NotificationLogEntry
InfraBackup *store.InfraBackupMeta InfraBackup *store.InfraBackupMeta
InfraBackupAge string 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" overallStatus := "ok"
@@ -230,6 +271,9 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
RecentNotifications: recentNotifs, RecentNotifications: recentNotifs,
InfraBackup: infraMeta, InfraBackup: infraMeta,
InfraBackupAge: infraBackupAge, InfraBackupAge: infraBackupAge,
ControllerURL: controllerURL,
LatestVersion: latestVersion,
UpdateAvailable: updateAvailable,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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) { func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
data, err := templateFS.ReadFile("templates/style.css") data, err := templateFS.ReadFile("templates/style.css")
if err != nil { if err != nil {
+79
View File
@@ -183,6 +183,85 @@
{{end}} {{end}}
</section> </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 --> <!-- Notifications -->
<section class="card"> <section class="card">
<h2>Notifications</h2> <h2>Notifications</h2>
+146
View File
@@ -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
}