mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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:
co-authored by
Claude Opus 4.7
parent
cb81be3143
commit
29d611f9c1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user