From f791145976bddc67c3bc6d890d6566736cb069e9 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 19 May 2026 19:36:56 +0200 Subject: [PATCH] feat(service): add Health tab with datastore checks and quick fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discussion #295 surfaced that a paired device without Sources.xml silently breaks playback — /full omits TUNEIN and selection fails with 1005. initializeDefaultSources only runs at startup over existing devices, so a device that checks in later is never seeded. Add a Health tab to the admin UI that runs registered checks against the datastore and offers one-click remediations. The first check flags missing Sources.xml per device; its quick fix writes the canonical defaults via SaveConfiguredSources. The check/fix registry is designed so adding Presets.xml, Recents.xml, or future reachability probes is a one-file diff. - New /setup/health (GET) and /setup/health/fix (POST) routes - pkg/service/health: Registry, Check, Finding, QuickFix types - Sources.xml-present check + create_default_sources fix - "7. Health" tab in pkg/service/handlers/web/ Inspired by issue #327's MAINTENANCE tab proposal; curl/URL helper content from that issue can slot into the same tab in a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/soundtouch-service/main.go | 3 + .../testdata/router_routes.txt | 2 + .../handlers/handlers_health_checks.go | 94 +++++++ .../handlers/handlers_health_checks_test.go | 211 +++++++++++++++ pkg/service/handlers/server.go | 5 + pkg/service/handlers/web/index.html | 18 ++ pkg/service/handlers/web/js/script.js | 184 +++++++++++++ pkg/service/health/checks_sources.go | 97 +++++++ pkg/service/health/checks_sources_test.go | 149 +++++++++++ pkg/service/health/health.go | 242 ++++++++++++++++++ pkg/service/health/health_test.go | 97 +++++++ 11 files changed, 1102 insertions(+) create mode 100644 pkg/service/handlers/handlers_health_checks.go create mode 100644 pkg/service/handlers/handlers_health_checks_test.go create mode 100644 pkg/service/health/checks_sources.go create mode 100644 pkg/service/health/checks_sources_test.go create mode 100644 pkg/service/health/health.go create mode 100644 pkg/service/health/health_test.go 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) + } +}