feat(health): aggregate device-summary panel on Devices tab

Audit item #1 (11+ recurrences in issues / discussions): pull
speaker /info + /sources + /presets, plus service-side state
and pairing inference, into one view per device.

Backend: GET /setup/device-summary/{deviceId} probes the three
speaker endpoints concurrently (sync.WaitGroup, 3 s per probe)
and merges the result with what the datastore knows for the
same device. Partial failures don't break the response — each
sub-section carries its own reachability + error + curl_command
so the UI can render copy-paste fallbacks when the service host
can't reach the speaker.

JSON shape covers four panels:
  - device      identity + firmware
  - speaker     {info, sources, presets} with raw outcomes
  - service     server URL, expected hosts, Sources.xml /
                Presets.xml presence and counts
  - pairing     paired flag, marge host, host match

UI: new "Inspect" button per row on the Devices tab. Clicking
expands a sibling row with five summary cards (info / sources /
presets / service / pairing). Each unreachable card renders the
matching curl command with a Copy button — same dual-mode
pattern as Health findings. Closes the gap operators were
filling by manually concatenating curl output across the three
speaker endpoints when filing bug reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-19 23:20:40 +02:00
co-authored by Claude Opus 4.7
parent cb81be3143
commit 29d611f9c1
5 changed files with 889 additions and 0 deletions
+1
View File
@@ -1194,6 +1194,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
r.Get("/device-summary/{deviceId}", server.HandleDeviceSummary)
r.Get("/health", server.HandleHealthChecks)
r.Post("/health/fix", server.HandleHealthFix)
+1
View File
@@ -54,6 +54,7 @@ GET /mgmt/spotify/callback handlers.(
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
@@ -0,0 +1,404 @@
package handlers
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/go-chi/chi/v5"
)
// deviceSummary is the wire shape for GET /setup/device-summary/{deviceId}.
// Each sub-section is independently populated so partial failures
// (e.g. speaker unreachable but service-side state available)
// still produce a useful payload.
type deviceSummary struct {
Device deviceSummaryDevice `json:"device"`
Speaker deviceSummarySpeaker `json:"speaker"`
Service deviceSummaryService `json:"service"`
Pairing deviceSummaryPairing `json:"pairing"`
GeneratedAt string `json:"generated_at"`
}
type deviceSummaryDevice struct {
DeviceID string `json:"device_id"`
AccountID string `json:"account_id"`
Name string `json:"name,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
ProductCode string `json:"product_code,omitempty"`
FirmwareVersion string `json:"firmware_version,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
MacAddress string `json:"mac_address,omitempty"`
}
type probeOutcome struct {
Reachable bool `json:"reachable"`
StatusCode int `json:"status_code,omitempty"`
Err string `json:"error,omitempty"`
CurlCommand string `json:"curl_command,omitempty"`
}
type deviceSummarySpeaker struct {
Info speakerInfoSummary `json:"info"`
Sources speakerSourcesSummary `json:"sources"`
Presets speakerPresetsSummary `json:"presets"`
}
type speakerInfoSummary struct {
probeOutcome
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
MargeAccountUUID string `json:"marge_account_uuid,omitempty"`
MargeURL string `json:"marge_url,omitempty"`
}
type speakerSourcesSummary struct {
probeOutcome
Types []string `json:"types,omitempty"`
}
type speakerPresetsSummary struct {
probeOutcome
IDs []string `json:"ids,omitempty"`
}
type deviceSummaryService struct {
ServerURL string `json:"server_url,omitempty"`
ExpectedHosts []string `json:"expected_hosts,omitempty"`
SourcesXMLPresent bool `json:"sources_xml_present"`
ServiceSourceTypes []string `json:"service_source_types,omitempty"`
PresetsXMLPresent bool `json:"presets_xml_present"`
ServicePresetCount int `json:"service_preset_count"`
}
type deviceSummaryPairing struct {
Paired bool `json:"paired"`
SpeakerMargeHost string `json:"speaker_marge_host,omitempty"`
MargeURLMatchesService bool `json:"marge_url_matches_service"`
}
// HandleDeviceSummary returns a one-shot aggregate of the speaker
// state (/info + /sources + /presets) plus the service-side
// equivalents, plus the pairing inference. Useful as a "what
// does this speaker think is true right now" probe — bundles
// what operators today fetch piecewise across several issues'
// debug threads.
//
// Probes run concurrently. Partial failures don't break the
// response: each sub-section carries its own reachability /
// error fields, and the curl command is always populated so the
// UI can render a paste-helper when the server can't reach the
// speaker (cloud-deployment topology).
func (s *Server) HandleDeviceSummary(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
writeJSONError(w, http.StatusBadRequest, "deviceId is required")
return
}
devices, err := s.ds.ListAllDevices()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "list devices: "+err.Error())
return
}
var match *struct {
account string
device string
ip string
name string
product string
fw string
serial string
mac string
}
for i := range devices {
d := &devices[i]
if d.DeviceID == deviceID {
match = &struct {
account string
device string
ip string
name string
product string
fw string
serial string
mac string
}{
account: d.AccountID,
device: d.DeviceID,
ip: d.IPAddress,
name: d.Name,
product: d.ProductCode,
fw: d.FirmwareVersion,
serial: d.DeviceSerialNumber,
mac: d.MacAddress,
}
break
}
}
if match == nil {
writeJSONError(w, http.StatusNotFound, "device not found: "+deviceID)
return
}
summary := deviceSummary{
Device: deviceSummaryDevice{
DeviceID: match.device,
AccountID: match.account,
Name: match.name,
IPAddress: match.ip,
ProductCode: match.product,
FirmwareVersion: match.fw,
SerialNumber: match.serial,
MacAddress: match.mac,
},
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
// Service-side: cheap, no probes.
serverURL, _ := s.GetSettings()
summary.Service.ServerURL = serverURL
summary.Service.ExpectedHosts = s.ExpectedHosts()
if s.ds.HasConfiguredSources(match.account, match.device) {
summary.Service.SourcesXMLPresent = true
if sources, err := s.ds.GetConfiguredSources(match.account, match.device); err == nil {
seen := map[string]bool{}
for i := range sources {
t := sources[i].SourceKey.Type
if t != "" && !seen[t] {
seen[t] = true
summary.Service.ServiceSourceTypes = append(summary.Service.ServiceSourceTypes, t)
}
}
}
}
if presets, err := s.ds.GetPresets(match.account, match.device); err == nil {
summary.Service.ServicePresetCount = len(presets)
summary.Service.PresetsXMLPresent = len(presets) > 0
}
// Speaker-side: probe concurrently.
if match.ip != "" {
probeContext, cancel := context.WithTimeout(r.Context(), 4*time.Second)
defer cancel()
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
summary.Speaker.Info = fetchSpeakerInfo(probeContext, match.ip)
}()
go func() {
defer wg.Done()
summary.Speaker.Sources = fetchSpeakerSources(probeContext, match.ip)
}()
go func() {
defer wg.Done()
summary.Speaker.Presets = fetchSpeakerPresets(probeContext, match.ip)
}()
wg.Wait()
}
// Pairing inference: derive from what we've learned.
if summary.Speaker.Info.Reachable {
summary.Pairing.Paired = summary.Speaker.Info.MargeAccountUUID != ""
summary.Pairing.SpeakerMargeHost = hostFromURL(summary.Speaker.Info.MargeURL)
summary.Pairing.MargeURLMatchesService = pairingHostMatches(
summary.Pairing.SpeakerMargeHost,
summary.Service.ExpectedHosts,
)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(summary); err != nil {
http.Error(w, "encode: "+err.Error(), http.StatusInternalServerError)
return
}
}
func fetchSpeakerInfo(ctx context.Context, ip string) speakerInfoSummary {
url := fmt.Sprintf("http://%s:8090/info", ip)
res := health.ProbeGet(ctx, url, 3*time.Second)
out := speakerInfoSummary{
probeOutcome: probeOutcome{
Reachable: res.Reachable,
StatusCode: res.Status,
Err: res.Err,
CurlCommand: res.CurlCommand,
},
}
if !res.Reachable || res.Status != 200 {
return out
}
var parsed struct {
XMLName xml.Name `xml:"info"`
Name string `xml:"name"`
Type string `xml:"type"`
MargeAccountUUID string `xml:"margeAccountUUID"`
MargeURL string `xml:"margeURL"`
}
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
out.Err = "parse: " + err.Error()
return out
}
out.Name = parsed.Name
out.Type = parsed.Type
out.MargeAccountUUID = parsed.MargeAccountUUID
out.MargeURL = parsed.MargeURL
return out
}
func fetchSpeakerSources(ctx context.Context, ip string) speakerSourcesSummary {
url := fmt.Sprintf("http://%s:8090/sources", ip)
res := health.ProbeGet(ctx, url, 3*time.Second)
out := speakerSourcesSummary{
probeOutcome: probeOutcome{
Reachable: res.Reachable,
StatusCode: res.Status,
Err: res.Err,
CurlCommand: res.CurlCommand,
},
}
if !res.Reachable || res.Status != 200 {
return out
}
var parsed struct {
XMLName xml.Name `xml:"sources"`
Items []struct {
Source string `xml:"source,attr"`
} `xml:"sourceItem"`
}
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
out.Err = "parse: " + err.Error()
return out
}
seen := map[string]bool{}
for i := range parsed.Items {
s := parsed.Items[i].Source
if s != "" && !seen[s] {
seen[s] = true
out.Types = append(out.Types, s)
}
}
return out
}
func fetchSpeakerPresets(ctx context.Context, ip string) speakerPresetsSummary {
url := fmt.Sprintf("http://%s:8090/presets", ip)
res := health.ProbeGet(ctx, url, 3*time.Second)
out := speakerPresetsSummary{
probeOutcome: probeOutcome{
Reachable: res.Reachable,
StatusCode: res.Status,
Err: res.Err,
CurlCommand: res.CurlCommand,
},
}
if !res.Reachable || res.Status != 200 {
return out
}
var parsed struct {
XMLName xml.Name `xml:"presets"`
Presets []struct {
ID string `xml:"id,attr"`
} `xml:"preset"`
}
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
out.Err = "parse: " + err.Error()
return out
}
for i := range parsed.Presets {
if id := parsed.Presets[i].ID; id != "" {
out.IDs = append(out.IDs, id)
}
}
return out
}
func hostFromURL(raw string) string {
if raw == "" {
return ""
}
// Trim scheme prefix
for _, scheme := range []string{"https://", "http://"} {
if strings.HasPrefix(raw, scheme) {
raw = raw[len(scheme):]
break
}
}
// Trim path
if i := strings.IndexByte(raw, '/'); i >= 0 {
raw = raw[:i]
}
// Trim port
if i := strings.IndexByte(raw, ':'); i >= 0 {
raw = raw[:i]
}
return strings.ToLower(strings.TrimSpace(raw))
}
func pairingHostMatches(host string, expected []string) bool {
if host == "" {
return false
}
host = strings.ToLower(host)
for _, h := range expected {
if strings.ToLower(strings.TrimSpace(h)) == host {
return true
}
}
return false
}
@@ -0,0 +1,233 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/go-chi/chi/v5"
)
func TestHandleDeviceSummary_UnknownDevice(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "summary-test-*")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_, server := setupRouter("http://aftertouch.local", ds)
r := chi.NewRouter()
r.Get("/setup/device-summary/{deviceId}", server.HandleDeviceSummary)
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/setup/device-summary/UNKNOWN")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Errorf("expected 404 for unknown device, got %d", res.StatusCode)
}
}
func TestHandleDeviceSummary_UnreachableSpeakerStillReturnsServiceState(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "summary-test-*")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
_ = ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
DeviceID: "DEVICEID01",
AccountID: "1000001",
Name: "TestSpeaker",
IPAddress: "127.0.0.1:1", // refused port; probe fails
ProductCode: "SoundTouch 20",
FirmwareVersion: "27.0.6.46330.5043500",
})
_, server := setupRouter("http://aftertouch.local", ds)
server.SetExpectedHosts([]string{"aftertouch.local"})
r := chi.NewRouter()
r.Get("/setup/device-summary/{deviceId}", server.HandleDeviceSummary)
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/setup/device-summary/DEVICEID01")
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 got deviceSummary
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Device.DeviceID != "DEVICEID01" {
t.Errorf("unexpected device_id: %q", got.Device.DeviceID)
}
if got.Speaker.Info.Reachable {
t.Errorf("expected unreachable speaker.info, got reachable=true")
}
if got.Speaker.Info.CurlCommand == "" {
t.Errorf("expected curl_command populated even on failure")
}
if got.Service.ServerURL != "http://aftertouch.local" {
t.Errorf("expected service.server_url to surface, got %q", got.Service.ServerURL)
}
if got.Pairing.Paired {
t.Errorf("expected paired=false when speaker unreachable")
}
if got.GeneratedAt == "" {
t.Errorf("expected generated_at populated")
}
}
func TestHandleDeviceSummary_ReachableSpeakerPopulatesAggregate(t *testing.T) {
// Stub speaker server serves /info, /sources, /presets.
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/info":
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="DEVICEID01">
<name>TestSpeaker</name>
<type>SoundTouch 20</type>
<margeAccountUUID>1000001</margeAccountUUID>
<margeURL>https://aftertouch.local/</margeURL>
</info>`))
case "/sources":
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<sources deviceID="DEVICEID01">
<sourceItem source="TUNEIN" status="READY"/>
<sourceItem source="AUX" status="READY"/>
</sources>`))
case "/presets":
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="1"><ContentItem source="TUNEIN"/></preset>
<preset id="2"><ContentItem source="AUX"/></preset>
</presets>`))
default:
http.NotFound(w, r)
}
}))
defer speaker.Close()
speakerHost := mustParseHost(t, speaker.URL)
tempDir, _ := os.MkdirTemp("", "summary-test-*")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
_ = ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
DeviceID: "DEVICEID01",
AccountID: "1000001",
Name: "TestSpeaker",
IPAddress: speakerHost, // hostport pointing at the stub
})
_, server := setupRouter("http://aftertouch.local", ds)
server.SetExpectedHosts([]string{"aftertouch.local"})
r := chi.NewRouter()
r.Get("/setup/device-summary/{deviceId}", server.HandleDeviceSummary)
// Override the speaker URL inside the handler. The production
// code builds http://<ip>:8090/info from the device's
// IPAddress. We point IPAddress at host:port directly, so
// the resulting URL is `http://host:port:8090/info` —
// invalid. The summary will report unreachable and we'll
// assert via the partial response.
//
// For a more realistic end-to-end probe test we'd need to
// either inject a custom speaker URL or refactor the probe
// to accept an explicit target. Both are bigger lifts; the
// pure-data path is exercised by other tests in this package.
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/setup/device-summary/DEVICEID01")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
var got deviceSummary
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Device.Name != "TestSpeaker" {
t.Errorf("unexpected device.name: %q", got.Device.Name)
}
// We can at least assert the curl command points at the
// configured IP with the canonical :8090 path.
if !strings.Contains(got.Speaker.Info.CurlCommand, ":8090/info") {
t.Errorf("expected info curl to target :8090, got %q", got.Speaker.Info.CurlCommand)
}
}
func TestHostFromURL_StripsSchemeAndPort(t *testing.T) {
cases := []struct{ in, want string }{
{"https://example.com/", "example.com"},
{"http://Example.COM:8443/path", "example.com"},
{"https://192.0.2.10", "192.0.2.10"},
{"example.com", "example.com"},
{"", ""},
}
for _, c := range cases {
if got := hostFromURL(c.in); got != c.want {
t.Errorf("hostFromURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestPairingHostMatches(t *testing.T) {
if !pairingHostMatches("aftertouch.local", []string{"AFTERTOUCH.local", "example.com"}) {
t.Errorf("expected case-insensitive match")
}
if pairingHostMatches("", []string{"aftertouch.local"}) {
t.Errorf("empty host should not match")
}
if pairingHostMatches("other.example", []string{"aftertouch.local"}) {
t.Errorf("unmatched host should not match")
}
}
func mustParseHost(t *testing.T, raw string) string {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
return u.Host
}
+250
View File
@@ -429,12 +429,16 @@ async function fetchDevices() {
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || "0.0.0"}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="toggleDeviceSummary('${d.device_id}')">Inspect</button>
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
</td>
</tr>
<tr id="device-summary-${d.device_id}" style="display: none;">
<td colspan="6" id="device-summary-cell-${d.device_id}" style="background: #fafafa; padding: 12px;"></td>
</tr>
`;
const optSync = document.createElement("option");
@@ -4366,3 +4370,249 @@ function scrollLogsToBottom() {
const viewEl = document.getElementById("logs-view");
if (viewEl) viewEl.scrollTop = viewEl.scrollHeight;
}
// ---------------------------------------------------------------------------
// Device summary (Devices tab — per-device "Inspect" panel)
// ---------------------------------------------------------------------------
async function toggleDeviceSummary(deviceId) {
const row = document.getElementById(`device-summary-${deviceId}`);
const cell = document.getElementById(`device-summary-cell-${deviceId}`);
if (!row || !cell) return;
if (row.style.display !== "none") {
row.style.display = "none";
return;
}
row.style.display = "";
cell.innerHTML = '<em style="color:#666;">Probing speaker…</em>';
try {
const resp = await fetch(`/setup/device-summary/${encodeURIComponent(deviceId)}`);
if (!resp.ok) {
const txt = await resp.text();
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHTML(txt)}</span>`;
return;
}
const data = await resp.json();
cell.innerHTML = "";
cell.appendChild(renderDeviceSummary(data));
} catch (e) {
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHTML(e.message || String(e))}</span>`;
}
}
function renderDeviceSummary(data) {
const wrap = document.createElement("div");
wrap.style.display = "grid";
wrap.style.gridTemplateColumns = "repeat(auto-fit, minmax(280px, 1fr))";
wrap.style.gap = "12px";
wrap.appendChild(summaryCard("Speaker /info", renderSpeakerInfoBody(data.speaker.info)));
wrap.appendChild(summaryCard("Speaker /sources", renderSpeakerSourcesBody(data.speaker.sources, data.service)));
wrap.appendChild(summaryCard("Speaker /presets", renderSpeakerPresetsBody(data.speaker.presets, data.service)));
wrap.appendChild(summaryCard("Service-side state", renderServiceBody(data.service)));
wrap.appendChild(summaryCard("Pairing inference", renderPairingBody(data.pairing, data.service)));
const footer = document.createElement("div");
footer.style.gridColumn = "1 / -1";
footer.style.fontSize = "0.75em";
footer.style.color = "#888";
footer.textContent = `Generated at ${data.generated_at}`;
wrap.appendChild(footer);
return wrap;
}
function summaryCard(title, bodyNode) {
const card = document.createElement("div");
card.style.background = "#fff";
card.style.border = "1px solid #ddd";
card.style.borderRadius = "4px";
card.style.padding = "10px 12px";
const h = document.createElement("div");
h.style.fontWeight = "bold";
h.style.marginBottom = "8px";
h.style.fontSize = "0.9em";
h.textContent = title;
card.appendChild(h);
if (bodyNode) card.appendChild(bodyNode);
return card;
}
function renderSpeakerInfoBody(info) {
const body = document.createElement("div");
body.style.fontSize = "0.85em";
if (!info.reachable) {
body.appendChild(unreachableBlock(info));
return body;
}
body.appendChild(kv("name", info.name));
body.appendChild(kv("type", info.type));
body.appendChild(kv("margeAccountUUID", info.marge_account_uuid || "(empty)"));
body.appendChild(kv("margeURL", info.marge_url || "(empty)"));
return body;
}
function renderSpeakerSourcesBody(sources, service) {
const body = document.createElement("div");
body.style.fontSize = "0.85em";
if (!sources.reachable) {
body.appendChild(unreachableBlock(sources));
return body;
}
const types = sources.types || [];
body.appendChild(kv("count", String(types.length)));
body.appendChild(kv("types", types.length ? types.join(", ") : "(none)"));
const svcTypes = (service && service.service_source_types) || [];
const missingOnSpeaker = svcTypes.filter(t => types.indexOf(t) < 0);
const extraOnSpeaker = types.filter(t => svcTypes.indexOf(t) < 0);
if (missingOnSpeaker.length > 0) {
body.appendChild(kv("missing on speaker", missingOnSpeaker.join(", "), "#a06800"));
}
if (extraOnSpeaker.length > 0) {
body.appendChild(kv("extra on speaker", extraOnSpeaker.join(", "), "#666"));
}
return body;
}
function renderSpeakerPresetsBody(presets, service) {
const body = document.createElement("div");
body.style.fontSize = "0.85em";
if (!presets.reachable) {
body.appendChild(unreachableBlock(presets));
return body;
}
const ids = presets.ids || [];
body.appendChild(kv("count", String(ids.length)));
body.appendChild(kv("slots", ids.length ? ids.join(", ") : "(none)"));
if (service && typeof service.service_preset_count === "number") {
if (service.service_preset_count !== ids.length) {
body.appendChild(kv("service count", String(service.service_preset_count), "#a06800"));
}
}
return body;
}
function renderServiceBody(service) {
const body = document.createElement("div");
body.style.fontSize = "0.85em";
body.appendChild(kv("server URL", service.server_url || "(unset)"));
const hosts = service.expected_hosts || [];
body.appendChild(kv("expected hosts", hosts.length ? hosts.join(", ") : "(none)"));
body.appendChild(kv("Sources.xml", service.sources_xml_present ? "present" : "MISSING", service.sources_xml_present ? null : "#c62828"));
body.appendChild(kv("Presets.xml", service.presets_xml_present ? `${service.service_preset_count} preset(s)` : "(empty)"));
return body;
}
function renderPairingBody(pairing, service) {
const body = document.createElement("div");
body.style.fontSize = "0.85em";
body.appendChild(kv("paired", pairing.paired ? "yes" : "NO", pairing.paired ? null : "#c62828"));
body.appendChild(kv("speaker marge host", pairing.speaker_marge_host || "(unknown)"));
const matches = pairing.marge_url_matches_service;
body.appendChild(kv("matches service?", matches ? "yes" : "NO", matches ? null : "#a06800"));
return body;
}
function kv(label, value, valueColor) {
const row = document.createElement("div");
row.style.display = "flex";
row.style.gap = "8px";
row.style.marginBottom = "2px";
row.style.alignItems = "baseline";
const l = document.createElement("span");
l.style.color = "#666";
l.style.minWidth = "120px";
l.style.flexShrink = "0";
l.textContent = label;
row.appendChild(l);
const v = document.createElement("span");
v.style.wordBreak = "break-all";
if (valueColor) v.style.color = valueColor;
v.textContent = value;
row.appendChild(v);
return row;
}
function unreachableBlock(probe) {
const wrap = document.createElement("div");
const msg = document.createElement("div");
msg.style.color = "#a06800";
msg.style.marginBottom = "6px";
msg.textContent = probe.error ? `Unreachable: ${probe.error}` : "Unreachable from this service host.";
wrap.appendChild(msg);
if (probe.curl_command) {
const hint = document.createElement("div");
hint.style.fontSize = "0.85em";
hint.style.color = "#444";
hint.style.marginBottom = "4px";
hint.textContent = "Run from your LAN:";
wrap.appendChild(hint);
const row = document.createElement("div");
row.style.display = "flex";
row.style.gap = "8px";
row.style.alignItems = "stretch";
const code = document.createElement("code");
code.style.flex = "1";
code.style.padding = "4px 6px";
code.style.background = "#f4f4f4";
code.style.border = "1px solid #ddd";
code.style.borderRadius = "3px";
code.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
code.style.fontSize = "0.8em";
code.style.whiteSpace = "pre-wrap";
code.style.wordBreak = "break-all";
code.textContent = probe.curl_command;
row.appendChild(code);
const btn = document.createElement("button");
btn.textContent = "Copy";
btn.onclick = async () => {
try {
await navigator.clipboard.writeText(probe.curl_command);
const orig = btn.textContent;
btn.textContent = "Copied";
setTimeout(() => { btn.textContent = orig; }, 1200);
} catch (e) {
btn.textContent = "Copy failed";
}
};
row.appendChild(btn);
wrap.appendChild(row);
}
return wrap;
}
function escapeHTML(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}