diff --git a/go.mod b/go.mod index 65f1a87..c4ead91 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/larslaskowski/pimonitor -go 1.26.7 +go 1.26.9 require gopkg.in/yaml.v3 v3.0.1 diff --git a/internal/alert/notify.go b/internal/alert/notify.go index bf23fa5..38cbe66 100644 --- a/internal/alert/notify.go +++ b/internal/alert/notify.go @@ -414,14 +414,15 @@ func formatMessage(ev Event) string { ev.Metric, resource, ev.Kind, ev.From, ev.To, ev.Value) } -// eventReaches reports whether an event's severity reaches at least min. An -// event is relevant to a webhook when either side of the transition is at or -// above the webhook's minimum level, so a "min_level: crit" webhook still -// receives the cleared event when a metric drops out of crit. -func eventReaches(ev Event, min Level) bool { +// eventReaches reports whether an event's severity reaches at least +// minLevel. An event is relevant to a webhook when either side of the +// transition is at or above the webhook's minimum level, so a +// "min_level: crit" webhook still receives the cleared event when a metric +// drops out of crit. +func eventReaches(ev Event, minLevel Level) bool { peak := severity(ev.From) if s := severity(ev.To); s > peak { peak = s } - return peak >= severity(min) + return peak >= severity(minLevel) } diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 1f5f93c..98dbc06 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -26,7 +26,7 @@ func writeJSON(w http.ResponseWriter, log func(msg string, args ...any), v any) // Config{} directly; production always sets it from // config.Config.HealthzMaxStaleness(...), which never returns zero. func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { - if max := s.cfg.HealthzMaxStaleness; max > 0 && time.Since(s.metrics.Snapshot().Timestamp) > max { + if maxStale := s.cfg.HealthzMaxStaleness; maxStale > 0 && time.Since(s.metrics.Snapshot().Timestamp) > maxStale { http.Error(w, "stale: latest snapshot is older than the configured healthz_max_staleness_seconds", http.StatusServiceUnavailable) return } diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 7f11b76..3b26f53 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -1197,10 +1197,12 @@ return; } // Refresh immediately on return so the user never looks at a stale - // card while waiting for the first interval to elapse. - pollMetrics(); - pollHistory(); - pollAlerts(); + // card while waiting for the first interval to elapse. The three + // refreshes are independent and handle their own fetch errors, so they + // are fired concurrently and deliberately not awaited. + void pollMetrics(); + void pollHistory(); + void pollAlerts(); startPolling(); }); } @@ -1228,5 +1230,5 @@ await reloadAll(); } - main(); + main().catch((e) => console.error('failed to initialise dashboard', e)); })(); diff --git a/internal/web/polling_test.go b/internal/web/polling_test.go index ef91ce8..1e5c9de 100644 --- a/internal/web/polling_test.go +++ b/internal/web/polling_test.go @@ -1,6 +1,7 @@ package web import ( + "regexp" "strings" "testing" ) @@ -61,3 +62,30 @@ func TestAppJS_PollFunctionsGuardInFlightRequests(t *testing.T) { } } } + +// TestAppJS_AsyncCallsAreNotFloating guards issue #168 (SonarCloud +// javascript:S9383): a call to one of app.js's own async functions written +// as a bare statement drops the returned promise, so any rejection surfaces +// only as an anonymous "unhandled rejection". Every such call must be +// awaited, explicitly discarded with `void` (fire-and-forget refreshes), or +// chained to a rejection handler (`main().catch(...)`) — all of which keep +// the call from starting the statement. +func TestAppJS_AsyncCallsAreNotFloating(t *testing.T) { + data, err := assetsFS.ReadFile("assets/app.js") + if err != nil { + t.Fatalf("read app.js: %v", err) + } + js := string(data) + + decls := regexp.MustCompile(`async function (\w+)\(`).FindAllStringSubmatch(js, -1) + if len(decls) == 0 { + t.Fatal("app.js: expected at least one async function declaration") + } + for _, d := range decls { + bare := regexp.MustCompile(`(?m)^[ \t]*` + d[1] + `\([^)]*\);`) + for _, loc := range bare.FindAllStringIndex(js, -1) { + line := strings.Count(js[:loc[0]], "\n") + 1 + t.Errorf("app.js:%d: %s() is called as a bare statement; await it, prefix it with void, or attach .catch", line, d[1]) + } + } +}