feat(service): add Health tab with datastore checks and quick fixes

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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-19 20:00:16 +02:00
co-authored by Claude Opus 4.7
parent 882d0633fb
commit f791145976
11 changed files with 1102 additions and 0 deletions
+3
View File
@@ -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 {
+2
View File
@@ -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
@@ -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
}
}
@@ -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)
}
}
+5
View File
@@ -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
}
+18
View File
@@ -36,6 +36,9 @@
<button class="tab-btn" onclick="openTab(event, 'tab-account')">
6. Local Account
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-health')">
7. Health
</button>
</div>
<!-- Tab 0: Overview -->
@@ -1490,6 +1493,21 @@
<div id="account-devices-list">Select an account to view devices.</div>
</div>
</div>
<!-- Tab 7: Health -->
<div id="tab-health" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>Service Health Checks</h2>
<button onclick="fetchHealth()">Refresh</button>
</div>
<p style="font-size: 0.9em; color: #555;">
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.
</p>
<div id="health-generated-at" style="font-size: 0.8em; color: #888; margin-bottom: 10px;"></div>
<div id="health-findings">Loading…</div>
</div>
</div>
<script src="/web/js/script.js"></script>
+184
View File
@@ -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;
}
}
+97
View File
@@ -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
}
+149
View File
@@ -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")
}
}
+242
View File
@@ -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: "<checkID>/<fixID>"
}
// 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
}
+97
View File
@@ -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)
}
}