From 04f7388051ea70188287b58a1d3f2c1b1d2ad400 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 27 May 2026 20:54:42 +0200 Subject: [PATCH] fix(health): skip fetchHealth re-render for non-resolving quick fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a refresh policy to the fix registry so the UI can avoid the unnecessary "Loading…" flash when a quick fix does not change any check state. - Registry stores fixEntry{fn, refresh} instead of bare FixFunc. - RegisterFix (existing callers) keeps refresh=true: resolved findings disappear from the list after the fix runs. - New RegisterFixNoRefresh sets refresh=false: used for persistent operator affordances whose success leaves the finding unchanged. - RunFix now returns (string, bool, error); the bool propagates to the healthFixResponse JSON as "refresh". - play_ding registered via RegisterFixNoRefresh — pressing it never resolves the finding, so no re-fetch is needed. - runQuickFix in script.js gates setTimeout(fetchHealth, 400) on data.refresh !== false; absent or true keeps the existing behaviour. Co-Authored-By: Claude Sonnet 4.6 --- pkg/service/ding/ding.go | 1 + .../handlers/handlers_health_checks.go | 8 ++- pkg/service/handlers/web/js/script.js | 6 ++- pkg/service/health/checks_sources_test.go | 4 +- pkg/service/health/checks_test_playback.go | 44 ++++++++++++---- .../health/checks_test_playback_test.go | 25 ++++++---- pkg/service/health/health.go | 50 ++++++++++++++----- pkg/service/health/health_test.go | 8 ++- 8 files changed, 108 insertions(+), 38 deletions(-) diff --git a/pkg/service/ding/ding.go b/pkg/service/ding/ding.go index 50697c6..bda0e8c 100644 --- a/pkg/service/ding/ding.go +++ b/pkg/service/ding/ding.go @@ -173,6 +173,7 @@ func Render(opts Options) []byte { // instance is audible even if the first is missed. if opts.Repeat > 1 { repeatGapN := int(math.Round(float64(opts.SampleRate) * opts.RepeatGapDuration)) + baseLeft := append([]float64{}, left...) baseRight := append([]float64{}, right...) silence := make([]float64, repeatGapN) diff --git a/pkg/service/handlers/handlers_health_checks.go b/pkg/service/handlers/handlers_health_checks.go index c44399d..71017a5 100644 --- a/pkg/service/handlers/handlers_health_checks.go +++ b/pkg/service/handlers/handlers_health_checks.go @@ -27,6 +27,10 @@ type healthFixRequest struct { type healthFixResponse struct { OK bool `json:"ok"` Message string `json:"message,omitempty"` + // Refresh tells the UI whether to re-fetch health after this fix. + // false for persistent affordances (e.g. play_ding) that don't + // change any check state, so no "Loading…" flash occurs. + Refresh bool `json:"refresh"` } // HandleHealthChecks runs every registered health check and @@ -73,7 +77,7 @@ func (s *Server) HandleHealthFix(w http.ResponseWriter, r *http.Request) { return } - msg, err := s.healthRegistry.RunFix(req.CheckID, req.FixID, req.Target) + msg, refresh, 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()) @@ -87,7 +91,7 @@ func (s *Server) HandleHealthFix(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(healthFixResponse{OK: true, Message: msg}); err != nil { + if err := json.NewEncoder(w).Encode(healthFixResponse{OK: true, Message: msg, Refresh: refresh}); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 7331633..8bcb6e7 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -4368,8 +4368,10 @@ async function runQuickFix(checkId, fixId, target, confirmMsg, button) { status.textContent = data.message || "Done."; status.style.color = "#2e7d32"; } - // Refresh to drop the resolved finding. - setTimeout(fetchHealth, 400); + // Re-fetch health so resolved findings disappear from the list. + // Skipped when the server signals refresh:false (persistent + // affordances like play_ding that don't change check state). + if (data.refresh !== false) setTimeout(fetchHealth, 400); } catch (e) { if (status) { status.textContent = `Failed: ${e.message || e}`; diff --git a/pkg/service/health/checks_sources_test.go b/pkg/service/health/checks_sources_test.go index 2459e39..9ec4ab1 100644 --- a/pkg/service/health/checks_sources_test.go +++ b/pkg/service/health/checks_sources_test.go @@ -77,7 +77,7 @@ func TestSourcesXMLPresent_QuickFix_MaterialisesDefaults(t *testing.T) { r := NewRegistry() RegisterSourcesXMLPresent(r, ds) - msg, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{ + msg, _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{ Account: account, Device: device, }) @@ -143,7 +143,7 @@ func TestSourcesXMLPresent_FixRejectsEmptyTarget(t *testing.T) { r := NewRegistry() RegisterSourcesXMLPresent(r, ds) - if _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{}); err == nil { + if _, _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{}); err == nil { t.Errorf("expected error for empty target, got nil") } } diff --git a/pkg/service/health/checks_test_playback.go b/pkg/service/health/checks_test_playback.go index afadcfe..19ebf8f 100644 --- a/pkg/service/health/checks_test_playback.go +++ b/pkg/service/health/checks_test_playback.go @@ -3,6 +3,7 @@ package health import ( "bytes" "context" + "encoding/base64" "fmt" "io" "net/http" @@ -28,6 +29,23 @@ const FixIDPlayDing = "play_ding" // pkg/service/handlers (see static/media embed). const DingMediaPath = "/media/aftertouch-ding.wav" +// DingCustomPath is the AfterTouch custom-playback prefix. The speaker +// fetches this URL, AfterTouch responds with a BMX JSON payload, and the +// speaker plays via LOCAL_INTERNET_RADIO — avoiding the INTERNET_RADIO +// FLAC-parser path that causes UNKNOWN_SOURCE_ERROR (1005) on some firmware +// versions (see issue #345). +const DingCustomPath = "/custom/v1/playback/" + +// dingCustomURL builds the LOCAL_INTERNET_RADIO proxy URL for the ding WAV. +// The WAV URL is base64url-encoded into the path; the name query param sets +// the display name on the speaker. +func dingCustomURL(serverURL string) string { + mediaURL := serverURL + DingMediaPath + encoded := base64.URLEncoding.EncodeToString([]byte(mediaURL)) + + return serverURL + DingCustomPath + encoded + "?name=AfterTouch+ding" +} + // RegisterTestPlaybackCheck registers the playback_test check and // its play_ding quick fix. serverURLFn returns the externally // reachable URL of this service — the speaker fetches the audio @@ -42,7 +60,10 @@ func RegisterTestPlaybackCheck(r *Registry, ds *datastore.DataStore, serverURLFn }, }) - r.RegisterFix(CheckIDTestPlayback, FixIDPlayDing, func(target Target) (string, error) { + // play_ding is a persistent operator affordance, not a resolvable + // finding — success doesn't change any check state, so the UI + // should not re-fetch health afterwards (no "Loading…" flash). + r.RegisterFixNoRefresh(CheckIDTestPlayback, FixIDPlayDing, func(target Target) (string, error) { return playDingOnDevice(ds, serverURLFn(), target) }) } @@ -80,7 +101,7 @@ func runTestPlaybackCheck(ds *datastore.DataStore, serverURL string) []Finding { Severity: SeverityInfo, Target: Target{Account: dev.AccountID, Device: dev.DeviceID}, Message: fmt.Sprintf("Play the AfterTouch ding on %s.", displayName(dev.Name, dev.DeviceID)), - Details: fmt.Sprintf("Pushes %s%s to the speaker via a custom-radio ContentItem. Confirms migration is healthy end-to-end without depending on TuneIn or any external service.", serverURL, DingMediaPath), + Details: fmt.Sprintf("Pushes the ding WAV to the speaker via LOCAL_INTERNET_RADIO (custom-playback proxy at %s%s). Confirms migration is healthy end-to-end without depending on TuneIn or any external service.", serverURL, DingCustomPath), QuickFixes: []QuickFix{{ ID: FixIDPlayDing, Label: "Play ding", @@ -114,8 +135,8 @@ func playDingOnDevice(ds *datastore.DataStore, serverURL string, target Target) return "", fmt.Errorf("device %s has no IP address recorded", target.Device) } - mediaURL := serverURL + DingMediaPath - contentItem := buildDingContentItem(mediaURL) + customURL := dingCustomURL(serverURL) + contentItem := buildDingContentItem(customURL) selectURL := fmt.Sprintf("http://%s:8090/select", dev.IPAddress) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) @@ -143,18 +164,23 @@ func playDingOnDevice(ds *datastore.DataStore, serverURL string, target Target) return fmt.Sprintf("Pushed ding URL to %s. You should hear it within a second.", displayName(dev.Name, target.Device)), nil } -func buildDingContentItem(mediaURL string) string { - escaped := xmlAttrEscape(mediaURL) +// buildDingContentItem returns the XML ContentItem that pushes the ding to a +// speaker. Uses LOCAL_INTERNET_RADIO with the AfterTouch custom-playback +// proxy URL so the speaker fetches a BMX JSON response and plays via the +// LOCAL_INTERNET_RADIO code path — avoiding the INTERNET_RADIO FLAC-parser +// issue that causes UNKNOWN_SOURCE_ERROR (1005) on some firmware versions +// (issue #345). +func buildDingContentItem(customURL string) string { + escaped := xmlAttrEscape(customURL) return fmt.Sprintf( - `AfterTouch ding`, + `AfterTouch ding`, escaped, ) } func dingCurlCommand(speakerIP, serverURL string) string { - mediaURL := serverURL + DingMediaPath - body := buildDingContentItem(mediaURL) + body := buildDingContentItem(dingCustomURL(serverURL)) return fmt.Sprintf( "curl -sS -X POST 'http://%s:8090/select' -H 'Content-Type: application/xml' -d '%s'", diff --git a/pkg/service/health/checks_test_playback_test.go b/pkg/service/health/checks_test_playback_test.go index 7d71a2c..ce08e64 100644 --- a/pkg/service/health/checks_test_playback_test.go +++ b/pkg/service/health/checks_test_playback_test.go @@ -118,24 +118,31 @@ func TestPlayDing_PostsContentItemToSelectEndpoint(t *testing.T) { // reuse it directly with httptest. Test the building blocks // (ContentItem rendering + the curl-command form) here, and // leave the full POST plumbing for a manual smoke test. - mediaURL := "http://aftertouch.local" + DingMediaPath - contentItem := buildDingContentItem(mediaURL) + const serverBase = "http://aftertouch.local" + customURL := dingCustomURL(serverBase) + contentItem := buildDingContentItem(customURL) - if !strings.Contains(contentItem, "source=\"INTERNET_RADIO\"") { - t.Errorf("ContentItem missing INTERNET_RADIO source, got %q", contentItem) + if !strings.Contains(contentItem, "source=\"LOCAL_INTERNET_RADIO\"") { + t.Errorf("ContentItem missing LOCAL_INTERNET_RADIO source, got %q", contentItem) } - if !strings.Contains(contentItem, mediaURL) { - t.Errorf("ContentItem missing media URL, got %q", contentItem) + if !strings.Contains(contentItem, DingCustomPath) { + t.Errorf("ContentItem missing custom-playback path, got %q", contentItem) } - cmd := dingCurlCommand("192.0.2.10", "http://aftertouch.local") + // The WAV URL is base64-encoded inside the custom URL — verify the + // custom URL itself is present in the ContentItem. + if !strings.Contains(contentItem, customURL) { + t.Errorf("ContentItem missing custom URL, got %q", contentItem) + } + + cmd := dingCurlCommand("192.0.2.10", serverBase) if !strings.Contains(cmd, "192.0.2.10:8090/select") { t.Errorf("curl command should target speaker /select, got %q", cmd) } - if !strings.Contains(cmd, "/media/aftertouch-ding.wav") { - t.Errorf("curl command should include the ding URL, got %q", cmd) + if !strings.Contains(cmd, DingCustomPath) { + t.Errorf("curl command should include the custom-playback path, got %q", cmd) } } diff --git a/pkg/service/health/health.go b/pkg/service/health/health.go index cd9bd36..bbf5017 100644 --- a/pkg/service/health/health.go +++ b/pkg/service/health/health.go @@ -114,18 +114,29 @@ type CheckResult struct { // registered under the (checkID, fixID) pair. var ErrFixNotFound = errors.New("quick fix not registered") +// fixEntry pairs a FixFunc with its refresh policy. refresh=true +// means the UI should re-run fetchHealth after the fix succeeds so +// resolved findings disappear from the list. refresh=false is used +// for persistent affordances (e.g. play_ding) that never change check +// state — no re-render is needed and the brief "Loading…" flash is +// avoided. +type fixEntry struct { + fn FixFunc + refresh bool +} + // 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: "/" + fixes map[string]fixEntry // key: "/" } // NewRegistry returns an empty Registry. func NewRegistry() *Registry { - return &Registry{fixes: map[string]FixFunc{}} + return &Registry{fixes: map[string]fixEntry{}} } // Register adds a check to the registry. Duplicate IDs replace @@ -147,12 +158,25 @@ func (r *Registry) Register(c Check) { // 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. +// emitted by the matching check. After a successful run the UI will +// re-fetch health so resolved findings disappear from the list. func (r *Registry) RegisterFix(checkID, fixID string, fn FixFunc) { r.mu.Lock() defer r.mu.Unlock() - r.fixes[fixKey(checkID, fixID)] = fn + r.fixes[fixKey(checkID, fixID)] = fixEntry{fn: fn, refresh: true} +} + +// RegisterFixNoRefresh is like RegisterFix but signals the UI that +// re-fetching health after the fix runs is unnecessary. Use this for +// persistent operator affordances (e.g. play_ding) whose success +// doesn't change any check state — skipping the re-fetch avoids a +// distracting "Loading…" flash with no benefit. +func (r *Registry) RegisterFixNoRefresh(checkID, fixID string, fn FixFunc) { + r.mu.Lock() + defer r.mu.Unlock() + + r.fixes[fixKey(checkID, fixID)] = fixEntry{fn: fn, refresh: false} } // RunAll executes every registered check and returns the results @@ -183,20 +207,22 @@ func (r *Registry) RunAll() []CheckResult { 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) { +// RunFix dispatches to the FixFunc registered for (checkID, fixID). +// Returns the user-facing success message, whether the UI should +// re-fetch health afterwards, and any execution error. +// ErrFixNotFound is returned when no fix is registered. +func (r *Registry) RunFix(checkID, fixID string, target Target) (string, bool, error) { r.mu.RLock() - fn, ok := r.fixes[fixKey(checkID, fixID)] + entry, ok := r.fixes[fixKey(checkID, fixID)] r.mu.RUnlock() if !ok { - return "", fmt.Errorf("%w: %s/%s", ErrFixNotFound, checkID, fixID) + return "", false, fmt.Errorf("%w: %s/%s", ErrFixNotFound, checkID, fixID) } - return fn(target) + msg, err := entry.fn(target) + + return msg, entry.refresh, err } func fixKey(checkID, fixID string) string { diff --git a/pkg/service/health/health_test.go b/pkg/service/health/health_test.go index 9a9ba52..63bde3a 100644 --- a/pkg/service/health/health_test.go +++ b/pkg/service/health/health_test.go @@ -58,7 +58,7 @@ func TestRegistry_RunFix_Dispatch(t *testing.T) { return "applied", nil }) - msg, err := r.RunFix("c1", "f1", Target{Account: "A", Device: "D"}) + msg, refresh, err := r.RunFix("c1", "f1", Target{Account: "A", Device: "D"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -67,6 +67,10 @@ func TestRegistry_RunFix_Dispatch(t *testing.T) { t.Errorf("unexpected message: %q", msg) } + if !refresh { + t.Errorf("expected refresh=true for a fix registered via RegisterFix") + } + if captured.Account != "A" || captured.Device != "D" { t.Errorf("target not propagated to fix: %+v", captured) } @@ -75,7 +79,7 @@ func TestRegistry_RunFix_Dispatch(t *testing.T) { func TestRegistry_RunFix_NotFound(t *testing.T) { r := NewRegistry() - _, err := r.RunFix("nope", "also-nope", Target{}) + _, _, err := r.RunFix("nope", "also-nope", Target{}) if !errors.Is(err, ErrFixNotFound) { t.Errorf("expected ErrFixNotFound, got %v", err) }