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
+31 -10
View File
@@ -29,6 +29,7 @@ type CustomerSummary struct {
ContainerRunning int
BackupLastSnapshot *time.Time
ReportJSON string
ControllerURL string
// Computed fields (not stored)
TimeSinceReport time.Duration
@@ -98,7 +99,14 @@ func (s *Store) migrate() error {
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.
@@ -201,6 +209,7 @@ func (s *Store) SaveReport(customerID string, reportJSON []byte) error {
// Parse denormalized fields from the JSON
var parsed struct {
ControllerVersion string `json:"controller_version"`
ControllerURL string `json:"controller_url"`
System struct {
CPUPercent float64 `json:"cpu_percent"`
MemoryPercent float64 `json:"memory_percent"`
@@ -229,13 +238,13 @@ func (s *Store) SaveReport(customerID string, reportJSON []byte) error {
_, err := s.db.Exec(`
INSERT INTO reports (customer_id, report_json, health_status, cpu_percent,
memory_percent, container_total, container_running,
backup_last_snapshot, controller_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
backup_last_snapshot, controller_version, controller_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
customerID, string(reportJSON),
parsed.Health.Status, parsed.System.CPUPercent,
parsed.System.MemoryPercent, parsed.Containers.Total,
parsed.Containers.Running, backupSnapshot,
parsed.ControllerVersion,
parsed.ControllerVersion, parsed.ControllerURL,
)
return err
}
@@ -246,7 +255,7 @@ func (s *Store) GetCustomers() ([]CustomerSummary, error) {
SELECT r.customer_id, r.received_at, r.report_json,
r.health_status, r.cpu_percent, r.memory_percent,
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
INNER JOIN (
SELECT customer_id, MAX(received_at) as max_time
@@ -265,11 +274,12 @@ func (s *Store) GetCustomers() ([]CustomerSummary, error) {
var c CustomerSummary
var receivedAt string
var backupSnapshot sql.NullString
var controllerURL sql.NullString
if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON,
&c.HealthStatus, &c.CPUPercent, &c.MemoryPercent,
&c.ContainerTotal, &c.ContainerRunning,
&backupSnapshot, &c.ControllerVersion); err != nil {
&backupSnapshot, &c.ControllerVersion, &controllerURL); err != nil {
return nil, err
}
@@ -282,6 +292,9 @@ func (s *Store) GetCustomers() ([]CustomerSummary, error) {
c.BackupLastSnapshot = &t
}
}
if controllerURL.Valid {
c.ControllerURL = controllerURL.String
}
// Parse customer_name from JSON
var report struct {
@@ -306,7 +319,7 @@ func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) {
SELECT customer_id, received_at, report_json,
health_status, cpu_percent, memory_percent,
container_total, container_running,
backup_last_snapshot, controller_version
backup_last_snapshot, controller_version, controller_url
FROM reports
WHERE customer_id = ?
ORDER BY received_at DESC
@@ -315,11 +328,12 @@ func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) {
var c CustomerSummary
var receivedAt string
var backupSnapshot sql.NullString
var controllerURL sql.NullString
if err := row.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON,
&c.HealthStatus, &c.CPUPercent, &c.MemoryPercent,
&c.ContainerTotal, &c.ContainerRunning,
&backupSnapshot, &c.ControllerVersion); err != nil {
&backupSnapshot, &c.ControllerVersion, &controllerURL); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
@@ -335,6 +349,9 @@ func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) {
c.BackupLastSnapshot = &t
}
}
if controllerURL.Valid {
c.ControllerURL = controllerURL.String
}
var report struct {
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,
health_status, cpu_percent, memory_percent,
container_total, container_running,
backup_last_snapshot, controller_version
backup_last_snapshot, controller_version, controller_url
FROM reports
WHERE customer_id = ? AND received_at >= ?
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 receivedAt string
var backupSnapshot sql.NullString
var controllerURL sql.NullString
if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON,
&c.HealthStatus, &c.CPUPercent, &c.MemoryPercent,
&c.ContainerTotal, &c.ContainerRunning,
&backupSnapshot, &c.ControllerVersion); err != nil {
&backupSnapshot, &c.ControllerVersion, &controllerURL); err != nil {
return nil, err
}
@@ -386,6 +404,9 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu
c.BackupLastSnapshot = &t
}
}
if controllerURL.Valid {
c.ControllerURL = controllerURL.String
}
history = append(history, c)
}