diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index 2cbf3e3..034fca1 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -1154,6 +1154,9 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
+ r.Get("/health", server.HandleHealthChecks)
+ r.Post("/health/fix", server.HandleHealthFix)
+
// Serve Stockholm setup wizard pages for paths not matched by the management API.
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
if stockholmHandler != nil {
diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt
index add3d3f..4e8d94a 100644
--- a/cmd/soundtouch-service/testdata/router_routes.txt
+++ b/cmd/soundtouch-service/testdata/router_routes.txt
@@ -58,6 +58,7 @@ GET /setup/devices/{deviceId}/events handlers.(
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
+GET /setup/health handlers.(*Server).HandleHealthChecks-fm
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
@@ -127,6 +128,7 @@ POST /setup/backup/{deviceId} handlers.(
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
+POST /setup/health/fix handlers.(*Server).HandleHealthFix-fm
POST /setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
diff --git a/pkg/service/handlers/handlers_health_checks.go b/pkg/service/handlers/handlers_health_checks.go
new file mode 100644
index 0000000..c44399d
--- /dev/null
+++ b/pkg/service/handlers/handlers_health_checks.go
@@ -0,0 +1,94 @@
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/gesellix/bose-soundtouch/pkg/service/health"
+)
+
+// healthChecksResponse is the wire shape for GET /setup/health.
+type healthChecksResponse struct {
+ GeneratedAt string `json:"generatedAt"`
+ Checks []health.CheckResult `json:"checks"`
+}
+
+// healthFixRequest is the wire shape for POST /setup/health/fix.
+// Target locates the entity the fix should act on; an empty
+// Account/Device pair is allowed for service-wide fixes.
+type healthFixRequest struct {
+ CheckID string `json:"checkId"`
+ FixID string `json:"fixId"`
+ Target health.Target `json:"target"`
+}
+
+type healthFixResponse struct {
+ OK bool `json:"ok"`
+ Message string `json:"message,omitempty"`
+}
+
+// HandleHealthChecks runs every registered health check and
+// returns the current findings. Safe to poll: checks are
+// expected to be cheap (filesystem stats, in-memory lookups).
+func (s *Server) HandleHealthChecks(w http.ResponseWriter, _ *http.Request) {
+ if s.healthRegistry == nil {
+ writeJSONError(w, http.StatusInternalServerError, "health registry not initialized")
+ return
+ }
+
+ resp := healthChecksResponse{
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ Checks: s.healthRegistry.RunAll(),
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+}
+
+// HandleHealthFix dispatches a quick-fix identified by
+// (checkId, fixId) against the supplied target. Returns 404 when
+// the fix isn't registered (typically a stale UI), 400 on a
+// malformed body, and 500 when the fix itself fails. The success
+// message comes from the FixFunc and is forwarded to the UI.
+func (s *Server) HandleHealthFix(w http.ResponseWriter, r *http.Request) {
+ if s.healthRegistry == nil {
+ writeJSONError(w, http.StatusInternalServerError, "health registry not initialized")
+ return
+ }
+
+ var req healthFixRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "Invalid request body")
+ return
+ }
+
+ if req.CheckID == "" || req.FixID == "" {
+ writeJSONError(w, http.StatusBadRequest, "checkId and fixId are required")
+ return
+ }
+
+ msg, err := s.healthRegistry.RunFix(req.CheckID, req.FixID, req.Target)
+ if err != nil {
+ if errors.Is(err, health.ErrFixNotFound) {
+ writeJSONError(w, http.StatusNotFound, err.Error())
+ return
+ }
+
+ writeJSONError(w, http.StatusInternalServerError, err.Error())
+
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(healthFixResponse{OK: true, Message: msg}); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+}
diff --git a/pkg/service/handlers/handlers_health_checks_test.go b/pkg/service/handlers/handlers_health_checks_test.go
new file mode 100644
index 0000000..5089d5f
--- /dev/null
+++ b/pkg/service/handlers/handlers_health_checks_test.go
@@ -0,0 +1,211 @@
+package handlers
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+ "github.com/gesellix/bose-soundtouch/pkg/service/datastore"
+ "github.com/gesellix/bose-soundtouch/pkg/service/health"
+ "github.com/go-chi/chi/v5"
+)
+
+func newHealthTestServer(t *testing.T) (*httptest.Server, *datastore.DataStore, string, string) {
+ t.Helper()
+
+ tempDir, err := os.MkdirTemp("", "handlers-health-test-*")
+ if err != nil {
+ t.Fatalf("temp dir: %v", err)
+ }
+
+ t.Cleanup(func() { os.RemoveAll(tempDir) })
+
+ ds := datastore.NewDataStore(tempDir)
+ _ = ds.Initialize()
+
+ account, device := "1000001", "DEVICEID01"
+ if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
+ DeviceID: device,
+ AccountID: account,
+ Name: "Speaker",
+ }); err != nil {
+ t.Fatalf("SaveDeviceInfo: %v", err)
+ }
+
+ _, server := setupRouter("http://localhost:8001", ds)
+
+ r := chi.NewRouter()
+ r.Get("/setup/health", server.HandleHealthChecks)
+ r.Post("/setup/health/fix", server.HandleHealthFix)
+
+ ts := httptest.NewServer(r)
+ t.Cleanup(ts.Close)
+
+ return ts, ds, account, device
+}
+
+func TestHandleHealthChecks_ReportsMissingSourcesXML(t *testing.T) {
+ ts, _, account, device := newHealthTestServer(t)
+
+ res, err := http.Get(ts.URL + "/setup/health")
+ if err != nil {
+ t.Fatalf("GET: %v", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", res.StatusCode)
+ }
+
+ var resp struct {
+ GeneratedAt string `json:"generatedAt"`
+ Checks []health.CheckResult `json:"checks"`
+ }
+ if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ if resp.GeneratedAt == "" {
+ t.Errorf("expected generatedAt to be populated")
+ }
+
+ if len(resp.Checks) == 0 {
+ t.Fatalf("expected at least one check")
+ }
+
+ var found *health.CheckResult
+ for i := range resp.Checks {
+ if resp.Checks[i].ID == health.CheckIDSourcesXMLPresent {
+ found = &resp.Checks[i]
+ break
+ }
+ }
+
+ if found == nil {
+ t.Fatalf("expected %q check in response", health.CheckIDSourcesXMLPresent)
+ }
+
+ if found.Severity != health.SeverityWarning {
+ t.Errorf("expected warning, got %q", found.Severity)
+ }
+
+ if len(found.Findings) != 1 {
+ t.Fatalf("expected 1 finding, got %d", len(found.Findings))
+ }
+
+ finding := found.Findings[0]
+ if finding.Target.Account != account || finding.Target.Device != device {
+ t.Errorf("finding target = %+v, want account=%s device=%s", finding.Target, account, device)
+ }
+}
+
+func TestHandleHealthFix_MaterialisesAndClearsFinding(t *testing.T) {
+ ts, ds, account, device := newHealthTestServer(t)
+
+ type fixReq struct {
+ CheckID string `json:"checkId"`
+ FixID string `json:"fixId"`
+ Target health.Target `json:"target"`
+ }
+
+ body, err := json.Marshal(fixReq{
+ CheckID: health.CheckIDSourcesXMLPresent,
+ FixID: health.FixIDCreateDefaultSources,
+ Target: health.Target{Account: account, Device: device},
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", res.StatusCode)
+ }
+
+ var fixResp struct {
+ OK bool `json:"ok"`
+ Message string `json:"message"`
+ }
+ if err := json.NewDecoder(res.Body).Decode(&fixResp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ if !fixResp.OK {
+ t.Errorf("expected ok=true, got %+v", fixResp)
+ }
+
+ if !ds.HasConfiguredSources(account, device) {
+ t.Fatalf("Sources.xml should exist after fix")
+ }
+
+ // Subsequent GET should now report SeverityOK for the check.
+ res2, err := http.Get(ts.URL + "/setup/health")
+ if err != nil {
+ t.Fatalf("re-GET: %v", err)
+ }
+ defer res2.Body.Close()
+
+ var resp struct {
+ Checks []health.CheckResult `json:"checks"`
+ }
+ if err := json.NewDecoder(res2.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ for i := range resp.Checks {
+ if resp.Checks[i].ID != health.CheckIDSourcesXMLPresent {
+ continue
+ }
+ if resp.Checks[i].Severity != health.SeverityOK {
+ t.Errorf("expected OK after fix, got %q", resp.Checks[i].Severity)
+ }
+ if len(resp.Checks[i].Findings) != 0 {
+ t.Errorf("expected zero findings after fix, got %d", len(resp.Checks[i].Findings))
+ }
+ }
+}
+
+func TestHandleHealthFix_UnknownFixReturns404(t *testing.T) {
+ ts, _, _, _ := newHealthTestServer(t)
+
+ body, err := json.Marshal(struct {
+ CheckID string `json:"checkId"`
+ FixID string `json:"fixId"`
+ }{CheckID: "no_such_check", FixID: "no_such_fix"})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusNotFound {
+ t.Errorf("expected 404, got %d", res.StatusCode)
+ }
+}
+
+func TestHandleHealthFix_BadRequest(t *testing.T) {
+ ts, _, _, _ := newHealthTestServer(t)
+
+ res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader([]byte(`{"checkId":""}`)))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusBadRequest {
+ t.Errorf("expected 400, got %d", res.StatusCode)
+ }
+}
diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go
index d78bb7a..e28dd87 100644
--- a/pkg/service/handlers/server.go
+++ b/pkg/service/handlers/server.go
@@ -20,6 +20,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
+ "github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
@@ -62,6 +63,7 @@ type Server struct {
amazonRedirectURI string
amazonService *amazon.Service
peerObserver *peerObserver
+ healthRegistry *health.Registry
}
// RequestSnapshot represents an immutable snapshot of an HTTP request.
@@ -97,8 +99,11 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
discoveryInterval: 5 * time.Minute,
discoveryEnabled: true,
peerObserver: newPeerObserver(),
+ healthRegistry: health.NewRegistry(),
}
+ health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
+
return s
}
diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html
index 73fb8bc..02733c2 100644
--- a/pkg/service/handlers/web/index.html
+++ b/pkg/service/handlers/web/index.html
@@ -36,6 +36,9 @@
+
@@ -1490,6 +1493,21 @@
Select an account to view devices.
+
+
+
+
+
Service Health Checks
+
+
+
+ Runs a set of checks against the local datastore and flags
+ findings that may need attention. Quick fixes are offered
+ for issues the service knows how to remediate.
+
+
+
Loading…
+
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js
index ca05cd0..b55b337 100644
--- a/pkg/service/handlers/web/js/script.js
+++ b/pkg/service/handlers/web/js/script.js
@@ -507,6 +507,10 @@ function openTab(evt, tabId) {
fetchAccountList();
}
+ if (tabId === "tab-health") {
+ fetchHealth();
+ }
+
if (evt) {
evt.currentTarget.className += " active";
let hash = tabId;
@@ -3976,3 +3980,183 @@ document.addEventListener("DOMContentLoaded", () => {
fetchSettings();
triggerDiscovery();
});
+
+// ---------------------------------------------------------------------------
+// Health tab
+// ---------------------------------------------------------------------------
+
+async function fetchHealth() {
+ const findingsEl = document.getElementById("health-findings");
+ const generatedAtEl = document.getElementById("health-generated-at");
+ if (!findingsEl) return;
+
+ findingsEl.textContent = "Loading…";
+ if (generatedAtEl) generatedAtEl.textContent = "";
+
+ try {
+ const resp = await fetch("/setup/health");
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const data = await resp.json();
+ renderHealthChecks(data, findingsEl, generatedAtEl);
+ } catch (e) {
+ findingsEl.textContent = `Failed to load health checks: ${e.message || e}`;
+ }
+}
+
+function renderHealthChecks(data, findingsEl, generatedAtEl) {
+ if (generatedAtEl && data.generatedAt) {
+ generatedAtEl.textContent = `Last run: ${data.generatedAt}`;
+ }
+
+ const checks = data.checks || [];
+ if (checks.length === 0) {
+ findingsEl.textContent = "No checks are registered.";
+ return;
+ }
+
+ findingsEl.innerHTML = "";
+ for (const check of checks) {
+ findingsEl.appendChild(renderHealthCheck(check));
+ }
+}
+
+function renderHealthCheck(check) {
+ const box = document.createElement("div");
+ box.className = "summary-box";
+
+ const header = document.createElement("h3");
+ header.style.margin = "0 0 8px 0";
+ header.appendChild(severityBadge(check.severity));
+ header.appendChild(document.createTextNode(" " + check.title));
+ box.appendChild(header);
+
+ const idLine = document.createElement("div");
+ idLine.style.fontSize = "0.75em";
+ idLine.style.color = "#888";
+ idLine.style.marginBottom = "8px";
+ idLine.textContent = `id: ${check.id}`;
+ box.appendChild(idLine);
+
+ const findings = check.findings || [];
+ if (findings.length === 0) {
+ const ok = document.createElement("div");
+ ok.style.color = "#2e7d32";
+ ok.textContent = "✓ No issues detected.";
+ box.appendChild(ok);
+ return box;
+ }
+
+ for (const f of findings) {
+ box.appendChild(renderFinding(check.id, f));
+ }
+ return box;
+}
+
+function renderFinding(checkId, finding) {
+ const row = document.createElement("div");
+ row.style.borderTop = "1px solid #e0e0e0";
+ row.style.padding = "10px 0";
+
+ const title = document.createElement("div");
+ title.appendChild(severityBadge(finding.severity));
+ title.appendChild(document.createTextNode(" " + (finding.message || "")));
+ row.appendChild(title);
+
+ const target = finding.target || {};
+ if (target.account || target.device) {
+ const t = document.createElement("div");
+ t.style.fontSize = "0.8em";
+ t.style.color = "#666";
+ t.style.marginTop = "4px";
+ const parts = [];
+ if (target.account) parts.push(`account ${target.account}`);
+ if (target.device) parts.push(`device ${target.device}`);
+ t.textContent = parts.join(" · ");
+ row.appendChild(t);
+ }
+
+ if (finding.details) {
+ const d = document.createElement("div");
+ d.style.fontSize = "0.85em";
+ d.style.color = "#444";
+ d.style.marginTop = "6px";
+ d.textContent = finding.details;
+ row.appendChild(d);
+ }
+
+ const fixes = finding.quickFixes || [];
+ if (fixes.length > 0) {
+ const actions = document.createElement("div");
+ actions.style.marginTop = "8px";
+ actions.style.display = "flex";
+ actions.style.gap = "8px";
+ actions.style.flexWrap = "wrap";
+ for (const fix of fixes) {
+ const btn = document.createElement("button");
+ btn.textContent = fix.label || fix.id;
+ btn.onclick = () => runQuickFix(checkId, fix.id, target, fix.confirm, btn);
+ actions.appendChild(btn);
+ }
+ const status = document.createElement("span");
+ status.className = "health-fix-status";
+ status.style.fontSize = "0.85em";
+ status.style.alignSelf = "center";
+ actions.appendChild(status);
+ row.appendChild(actions);
+ }
+
+ return row;
+}
+
+function severityBadge(severity) {
+ const span = document.createElement("span");
+ span.style.fontSize = "0.75em";
+ span.style.padding = "2px 6px";
+ span.style.borderRadius = "10px";
+ span.style.fontWeight = "bold";
+ const palette = {
+ ok: { bg: "#e8f5e9", fg: "#2e7d32", label: "OK" },
+ info: { bg: "#e3f2fd", fg: "#1565c0", label: "INFO" },
+ warning: { bg: "#fff8e1", fg: "#a06800", label: "WARN" },
+ error: { bg: "#ffebee", fg: "#c62828", label: "ERROR" },
+ };
+ const p = palette[severity] || palette.info;
+ span.style.background = p.bg;
+ span.style.color = p.fg;
+ span.textContent = p.label;
+ return span;
+}
+
+async function runQuickFix(checkId, fixId, target, confirmMsg, button) {
+ if (confirmMsg && !window.confirm(confirmMsg)) return;
+
+ const status = button.parentElement.querySelector(".health-fix-status");
+ button.disabled = true;
+ if (status) {
+ status.textContent = "Applying…";
+ status.style.color = "#555";
+ }
+
+ try {
+ const resp = await fetch("/setup/health/fix", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ checkId, fixId, target }),
+ });
+ const data = await resp.json().catch(() => ({}));
+ if (!resp.ok) throw new Error(data.error || data.message || `HTTP ${resp.status}`);
+
+ if (status) {
+ status.textContent = data.message || "Done.";
+ status.style.color = "#2e7d32";
+ }
+ // Refresh to drop the resolved finding.
+ setTimeout(fetchHealth, 400);
+ } catch (e) {
+ if (status) {
+ status.textContent = `Failed: ${e.message || e}`;
+ status.style.color = "#c62828";
+ }
+ button.disabled = false;
+ }
+}
diff --git a/pkg/service/health/checks_sources.go b/pkg/service/health/checks_sources.go
new file mode 100644
index 0000000..4caa4c1
--- /dev/null
+++ b/pkg/service/health/checks_sources.go
@@ -0,0 +1,97 @@
+package health
+
+import (
+ "fmt"
+
+ "github.com/gesellix/bose-soundtouch/pkg/service/datastore"
+)
+
+// CheckSourcesXMLPresent is the built-in check for missing
+// Sources.xml on paired devices. Background:
+// initializeDefaultSources in cmd/soundtouch-service/main.go only
+// runs at startup over devices that already exist on disk, so a
+// device that first checks in *after* boot never gets its default
+// Sources.xml materialised. The speaker then absorbs whatever the
+// service serves on /streaming/account/{id}/full — usually without
+// TUNEIN — and playback fails with 1005 long after migration
+// looked successful. See discussion #295 for the trace.
+const (
+ CheckIDSourcesXMLPresent = "sources_xml_present"
+ FixIDCreateDefaultSources = "create_default_sources"
+)
+
+// RegisterSourcesXMLPresent registers the sources_xml_present
+// check and its create_default_sources quick fix against r,
+// binding both to ds.
+func RegisterSourcesXMLPresent(r *Registry, ds *datastore.DataStore) {
+ r.Register(Check{
+ ID: CheckIDSourcesXMLPresent,
+ Title: "Default sources are materialised on disk",
+ Run: func() []Finding {
+ return runSourcesXMLPresent(ds)
+ },
+ })
+
+ r.RegisterFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, func(target Target) (string, error) {
+ return fixCreateDefaultSources(ds, target)
+ })
+}
+
+func runSourcesXMLPresent(ds *datastore.DataStore) []Finding {
+ if ds == nil {
+ return nil
+ }
+
+ devices, err := ds.ListAllDevices()
+ if err != nil {
+ return []Finding{{
+ Severity: SeverityError,
+ Message: "Could not enumerate devices: " + err.Error(),
+ }}
+ }
+
+ var findings []Finding
+
+ for i := range devices {
+ dev := &devices[i]
+ if dev.AccountID == "" || dev.DeviceID == "" {
+ continue
+ }
+
+ if ds.HasConfiguredSources(dev.AccountID, dev.DeviceID) {
+ continue
+ }
+
+ findings = append(findings, Finding{
+ Severity: SeverityWarning,
+ Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
+ Message: "Sources.xml is missing for this device.",
+ Details: "The /streaming/account/{id}/full response will not advertise the default sources (TUNEIN, RADIO_BROWSER, AUX). Playback may fail with error 1005 until a Sources.xml is materialised.",
+ QuickFixes: []QuickFix{
+ {
+ ID: FixIDCreateDefaultSources,
+ Label: "Create default Sources.xml",
+ },
+ },
+ })
+ }
+
+ return findings
+}
+
+func fixCreateDefaultSources(ds *datastore.DataStore, target Target) (string, error) {
+ if ds == nil {
+ return "", fmt.Errorf("datastore unavailable")
+ }
+
+ if target.Account == "" || target.Device == "" {
+ return "", fmt.Errorf("account and device are required")
+ }
+
+ defaults := ds.GetDefaultSources()
+ if err := ds.SaveConfiguredSources(target.Account, target.Device, defaults); err != nil {
+ return "", fmt.Errorf("save Sources.xml: %w", err)
+ }
+
+ return fmt.Sprintf("Wrote default Sources.xml for %s", target.Device), nil
+}
diff --git a/pkg/service/health/checks_sources_test.go b/pkg/service/health/checks_sources_test.go
new file mode 100644
index 0000000..2459e39
--- /dev/null
+++ b/pkg/service/health/checks_sources_test.go
@@ -0,0 +1,149 @@
+package health
+
+import (
+ "os"
+ "testing"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+ "github.com/gesellix/bose-soundtouch/pkg/service/datastore"
+)
+
+func newTestDatastoreWithDevice(t *testing.T, account, device string) *datastore.DataStore {
+ t.Helper()
+
+ tempDir, err := os.MkdirTemp("", "health-test-*")
+ if err != nil {
+ t.Fatalf("temp dir: %v", err)
+ }
+
+ t.Cleanup(func() { os.RemoveAll(tempDir) })
+
+ ds := datastore.NewDataStore(tempDir)
+
+ if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
+ DeviceID: device,
+ AccountID: account,
+ Name: "TestSpeaker",
+ }); err != nil {
+ t.Fatalf("SaveDeviceInfo: %v", err)
+ }
+
+ return ds
+}
+
+func TestSourcesXMLPresent_FlagsMissingFile(t *testing.T) {
+ account, device := "1000001", "DEVICEID01"
+ ds := newTestDatastoreWithDevice(t, account, device)
+
+ if ds.HasConfiguredSources(account, device) {
+ t.Fatalf("precondition: device should not have Sources.xml yet")
+ }
+
+ r := NewRegistry()
+ RegisterSourcesXMLPresent(r, ds)
+
+ results := r.RunAll()
+ if len(results) != 1 {
+ t.Fatalf("expected 1 check result, got %d", len(results))
+ }
+
+ check := results[0]
+ if check.ID != CheckIDSourcesXMLPresent {
+ t.Errorf("unexpected check id %q", check.ID)
+ }
+
+ if check.Severity != SeverityWarning {
+ t.Errorf("expected warning severity, got %q", check.Severity)
+ }
+
+ if len(check.Findings) != 1 {
+ t.Fatalf("expected 1 finding, got %d", len(check.Findings))
+ }
+
+ finding := check.Findings[0]
+ if finding.Target.Account != account || finding.Target.Device != device {
+ t.Errorf("finding target %+v doesn't match device", finding.Target)
+ }
+
+ if len(finding.QuickFixes) != 1 || finding.QuickFixes[0].ID != FixIDCreateDefaultSources {
+ t.Errorf("expected create_default_sources quick fix, got %+v", finding.QuickFixes)
+ }
+}
+
+func TestSourcesXMLPresent_QuickFix_MaterialisesDefaults(t *testing.T) {
+ account, device := "1000001", "DEVICEID01"
+ ds := newTestDatastoreWithDevice(t, account, device)
+
+ r := NewRegistry()
+ RegisterSourcesXMLPresent(r, ds)
+
+ msg, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{
+ Account: account,
+ Device: device,
+ })
+ if err != nil {
+ t.Fatalf("RunFix: %v", err)
+ }
+
+ if msg == "" {
+ t.Errorf("expected non-empty success message")
+ }
+
+ if !ds.HasConfiguredSources(account, device) {
+ t.Fatalf("Sources.xml was not materialised by the fix")
+ }
+
+ // The defaults should include TUNEIN — that's the load-bearing
+ // reason for this check.
+ sources, err := ds.GetConfiguredSources(account, device)
+ if err != nil {
+ t.Fatalf("GetConfiguredSources: %v", err)
+ }
+
+ var sawTuneIn bool
+ for i := range sources {
+ if sources[i].SourceKeyType == "TUNEIN" {
+ sawTuneIn = true
+ break
+ }
+ }
+
+ if !sawTuneIn {
+ t.Errorf("expected TUNEIN among default sources, got %d entries without it", len(sources))
+ }
+
+ // Re-running the check should now report a clean state.
+ results := r.RunAll()
+ if results[0].Severity != SeverityOK {
+ t.Errorf("expected OK after fix, got %q", results[0].Severity)
+ }
+
+ if len(results[0].Findings) != 0 {
+ t.Errorf("expected no findings after fix, got %d", len(results[0].Findings))
+ }
+}
+
+func TestSourcesXMLPresent_NilDatastore(t *testing.T) {
+ r := NewRegistry()
+ RegisterSourcesXMLPresent(r, nil)
+
+ results := r.RunAll()
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(results))
+ }
+
+ if results[0].Severity != SeverityOK {
+ t.Errorf("nil datastore should produce no findings, got %q", results[0].Severity)
+ }
+}
+
+func TestSourcesXMLPresent_FixRejectsEmptyTarget(t *testing.T) {
+ ds := newTestDatastoreWithDevice(t, "1000001", "DEVICEID01")
+
+ r := NewRegistry()
+ RegisterSourcesXMLPresent(r, ds)
+
+ if _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{}); err == nil {
+ t.Errorf("expected error for empty target, got nil")
+ }
+}
diff --git a/pkg/service/health/health.go b/pkg/service/health/health.go
new file mode 100644
index 0000000..a2bf0ae
--- /dev/null
+++ b/pkg/service/health/health.go
@@ -0,0 +1,242 @@
+// Package health provides an extensible registry of operator-facing
+// health checks for the AfterTouch service. Checks inspect datastore
+// state (and, in future, speaker reachability or config) and emit
+// Findings that the admin UI renders under the Health tab. Each
+// Finding may carry one or more QuickFix descriptors; the
+// remediation itself is dispatched through a separate fix registry
+// keyed by (checkID, fixID), so the HTTP layer can never reference
+// a fix that isn't actually registered.
+package health
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+ "sync"
+)
+
+// Severity classifies a Finding's urgency. The UI sorts errors
+// first, then warnings, then info. An entire check with zero
+// findings is reported back at severity SeverityOK so the admin UI
+// can show a positive "✓ check passed" line instead of hiding the
+// check altogether.
+type Severity string
+
+// Recognised Severity values. The UI renders findings sorted with
+// errors first; the registry rolls up a CheckResult's severity to
+// the highest among its Findings (SeverityOK when there are none).
+const (
+ SeverityOK Severity = "ok"
+ SeverityInfo Severity = "info"
+ SeverityWarning Severity = "warning"
+ SeverityError Severity = "error"
+)
+
+// Target identifies what a Finding is about. Both fields are
+// optional: a service-wide finding leaves both empty, a
+// per-account finding fills Account only, and the common
+// per-device case fills both. The UI displays the populated fields
+// as a small label next to the finding.
+type Target struct {
+ Account string `json:"account,omitempty"`
+ Device string `json:"device,omitempty"`
+}
+
+// QuickFix is a remediation a user can trigger from the UI with a
+// single click. The ID is the registry key used to look up the
+// FixFunc at POST time. Label is what the button displays. Confirm
+// is optional UI guidance ("This will overwrite the existing
+// file"); empty string means no confirmation needed.
+type QuickFix struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Confirm string `json:"confirm,omitempty"`
+}
+
+// Finding is the unit of output from a check. Severity should be
+// SeverityWarning or SeverityError for findings that need
+// attention; SeverityInfo is for things the operator might want to
+// notice but doesn't need to act on. Target locates the finding
+// (per-device / per-account / service-wide). QuickFixes is
+// optional.
+type Finding struct {
+ Severity Severity `json:"severity"`
+ Target Target `json:"target"`
+ Message string `json:"message"`
+ Details string `json:"details,omitempty"`
+ QuickFixes []QuickFix `json:"quickFixes,omitempty"`
+}
+
+// RunFunc executes a check and returns its Findings. Returning a
+// nil or empty slice means the check passed; the registry will
+// then report severity SeverityOK for the check as a whole.
+type RunFunc func() []Finding
+
+// Check describes a registered check. ID is the stable identifier
+// used in API responses and in the FixFunc registry. Title is the
+// human-readable label shown in the UI.
+type Check struct {
+ ID string
+ Title string
+ Run RunFunc
+}
+
+// FixFunc executes a quick-fix on the given target and returns an
+// optional user-facing message describing what was done. An
+// error result is propagated to the UI as a fix failure.
+type FixFunc func(target Target) (string, error)
+
+// CheckResult is the per-check entry in the GET /setup/health
+// response. Severity rolls up from the contained Findings: error
+// > warning > info; with no findings the severity is SeverityOK.
+type CheckResult struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Severity Severity `json:"severity"`
+ Findings []Finding `json:"findings"`
+}
+
+// ErrFixNotFound is returned by RunFix when no FixFunc is
+// registered under the (checkID, fixID) pair.
+var ErrFixNotFound = errors.New("quick fix not registered")
+
+// Registry owns the set of checks and fixes for one Server
+// instance. The default zero value is not usable; construct via
+// NewRegistry.
+type Registry struct {
+ mu sync.RWMutex
+ checks []Check
+ fixes map[string]FixFunc // key: "/"
+}
+
+// NewRegistry returns an empty Registry.
+func NewRegistry() *Registry {
+ return &Registry{fixes: map[string]FixFunc{}}
+}
+
+// Register adds a check to the registry. Duplicate IDs replace
+// the prior entry; this is intentional so tests can override a
+// built-in check with a stub.
+func (r *Registry) Register(c Check) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ for i := range r.checks {
+ if r.checks[i].ID == c.ID {
+ r.checks[i] = c
+ return
+ }
+ }
+
+ r.checks = append(r.checks, c)
+}
+
+// RegisterFix associates a FixFunc with the given (checkID, fixID)
+// pair. A QuickFix with that ID can be advertised by any Finding
+// emitted by the matching check.
+func (r *Registry) RegisterFix(checkID, fixID string, fn FixFunc) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ r.fixes[fixKey(checkID, fixID)] = fn
+}
+
+// RunAll executes every registered check and returns the results
+// in registration order. Each result's Severity is the highest
+// severity among its Findings, or SeverityOK when there are none.
+func (r *Registry) RunAll() []CheckResult {
+ r.mu.RLock()
+ checks := make([]Check, len(r.checks))
+ copy(checks, r.checks)
+ r.mu.RUnlock()
+
+ out := make([]CheckResult, 0, len(checks))
+
+ for _, c := range checks {
+ findings := []Finding{}
+ if c.Run != nil {
+ findings = c.Run()
+ }
+
+ out = append(out, CheckResult{
+ ID: c.ID,
+ Title: c.Title,
+ Severity: rollupSeverity(findings),
+ Findings: sortFindings(findings),
+ })
+ }
+
+ return out
+}
+
+// RunFix dispatches to the FixFunc registered for (checkID,
+// fixID). The returned string is forwarded as the user-facing
+// success message. ErrFixNotFound is returned when no fix is
+// registered.
+func (r *Registry) RunFix(checkID, fixID string, target Target) (string, error) {
+ r.mu.RLock()
+ fn, ok := r.fixes[fixKey(checkID, fixID)]
+ r.mu.RUnlock()
+
+ if !ok {
+ return "", fmt.Errorf("%w: %s/%s", ErrFixNotFound, checkID, fixID)
+ }
+
+ return fn(target)
+}
+
+func fixKey(checkID, fixID string) string {
+ return checkID + "/" + fixID
+}
+
+func rollupSeverity(findings []Finding) Severity {
+ if len(findings) == 0 {
+ return SeverityOK
+ }
+
+ rank := map[Severity]int{
+ SeverityOK: 0,
+ SeverityInfo: 1,
+ SeverityWarning: 2,
+ SeverityError: 3,
+ }
+
+ worst := SeverityInfo
+ for i := range findings {
+ if rank[findings[i].Severity] > rank[worst] {
+ worst = findings[i].Severity
+ }
+ }
+
+ return worst
+}
+
+func sortFindings(findings []Finding) []Finding {
+ if len(findings) < 2 {
+ return findings
+ }
+
+ out := make([]Finding, len(findings))
+ copy(out, findings)
+
+ rank := map[Severity]int{
+ SeverityError: 0,
+ SeverityWarning: 1,
+ SeverityInfo: 2,
+ SeverityOK: 3,
+ }
+
+ sort.SliceStable(out, func(i, j int) bool {
+ if rank[out[i].Severity] != rank[out[j].Severity] {
+ return rank[out[i].Severity] < rank[out[j].Severity]
+ }
+
+ if out[i].Target.Account != out[j].Target.Account {
+ return out[i].Target.Account < out[j].Target.Account
+ }
+
+ return out[i].Target.Device < out[j].Target.Device
+ })
+
+ return out
+}
diff --git a/pkg/service/health/health_test.go b/pkg/service/health/health_test.go
new file mode 100644
index 0000000..9a9ba52
--- /dev/null
+++ b/pkg/service/health/health_test.go
@@ -0,0 +1,97 @@
+package health
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestRegistry_RunAll_NoFindingsReturnsOK(t *testing.T) {
+ r := NewRegistry()
+ r.Register(Check{
+ ID: "passes",
+ Title: "Always passes",
+ Run: func() []Finding { return nil },
+ })
+
+ results := r.RunAll()
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(results))
+ }
+
+ if results[0].Severity != SeverityOK {
+ t.Errorf("expected SeverityOK for empty findings, got %q", results[0].Severity)
+ }
+
+ if len(results[0].Findings) != 0 {
+ t.Errorf("expected zero findings, got %d", len(results[0].Findings))
+ }
+}
+
+func TestRegistry_RunAll_SeverityRollup(t *testing.T) {
+ r := NewRegistry()
+ r.Register(Check{
+ ID: "mixed",
+ Run: func() []Finding {
+ return []Finding{
+ {Severity: SeverityInfo, Message: "info"},
+ {Severity: SeverityWarning, Message: "warn"},
+ {Severity: SeverityError, Message: "err"},
+ }
+ },
+ })
+
+ results := r.RunAll()
+ if results[0].Severity != SeverityError {
+ t.Errorf("expected SeverityError rollup, got %q", results[0].Severity)
+ }
+
+ if results[0].Findings[0].Severity != SeverityError {
+ t.Errorf("expected error to sort first, got %q", results[0].Findings[0].Severity)
+ }
+}
+
+func TestRegistry_RunFix_Dispatch(t *testing.T) {
+ r := NewRegistry()
+ var captured Target
+ r.RegisterFix("c1", "f1", func(t Target) (string, error) {
+ captured = t
+ return "applied", nil
+ })
+
+ msg, err := r.RunFix("c1", "f1", Target{Account: "A", Device: "D"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if msg != "applied" {
+ t.Errorf("unexpected message: %q", msg)
+ }
+
+ if captured.Account != "A" || captured.Device != "D" {
+ t.Errorf("target not propagated to fix: %+v", captured)
+ }
+}
+
+func TestRegistry_RunFix_NotFound(t *testing.T) {
+ r := NewRegistry()
+
+ _, err := r.RunFix("nope", "also-nope", Target{})
+ if !errors.Is(err, ErrFixNotFound) {
+ t.Errorf("expected ErrFixNotFound, got %v", err)
+ }
+}
+
+func TestRegistry_Register_ReplacesByID(t *testing.T) {
+ r := NewRegistry()
+ r.Register(Check{ID: "x", Title: "first", Run: func() []Finding { return nil }})
+ r.Register(Check{ID: "x", Title: "second", Run: func() []Finding { return nil }})
+
+ results := r.RunAll()
+ if len(results) != 1 {
+ t.Fatalf("expected 1 check after replace, got %d", len(results))
+ }
+
+ if results[0].Title != "second" {
+ t.Errorf("expected title to be replaced, got %q", results[0].Title)
+ }
+}