| @@ -145,6 +145,9 @@ func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { | |||||
| if queue, ok := stats["queue"]; ok { | if queue, ok := stats["queue"]; ok { | ||||
| status["queue"] = queue | status["queue"] = queue | ||||
| } | } | ||||
| if runtimeState, ok := stats["state"]; ok { | |||||
| status["runtimeState"] = runtimeState | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -8,8 +8,8 @@ import ( | |||||
| "net/http/httptest" | "net/http/httptest" | ||||
| "testing" | "testing" | ||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | |||||
| "github.com/jan/fm-rds-tx/internal/audio" | "github.com/jan/fm-rds-tx/internal/audio" | ||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | |||||
| "github.com/jan/fm-rds-tx/internal/output" | "github.com/jan/fm-rds-tx/internal/output" | ||||
| ) | ) | ||||
| @@ -92,6 +92,23 @@ func TestStatusReportsQueueStats(t *testing.T) { | |||||
| } | } | ||||
| } | } | ||||
| func TestStatusReportsRuntimeState(t *testing.T) { | |||||
| srv := NewServer(cfgpkg.Default()) | |||||
| srv.SetTXController(&fakeTXController{stats: map[string]any{"state": "faulted"}}) | |||||
| rec := httptest.NewRecorder() | |||||
| srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil)) | |||||
| if rec.Code != 200 { | |||||
| t.Fatalf("status: %d", rec.Code) | |||||
| } | |||||
| var body map[string]any | |||||
| if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { | |||||
| t.Fatalf("unmarshal runtime state: %v", err) | |||||
| } | |||||
| if body["runtimeState"] != "faulted" { | |||||
| t.Fatalf("expected runtimeState faulted, got %v", body["runtimeState"]) | |||||
| } | |||||
| } | |||||
| func TestDryRunEndpoint(t *testing.T) { | func TestDryRunEndpoint(t *testing.T) { | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| @@ -301,4 +318,4 @@ func (f *fakeTXController) TXStats() map[string]any { | |||||
| return map[string]any{} | return map[string]any{} | ||||
| } | } | ||||
| func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr } | func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr } | ||||
| func (f *fakeTXController) ResetFault() error { return f.resetErr } | |||||
| func (f *fakeTXController) ResetFault() error { return f.resetErr } | |||||
| @@ -1764,14 +1764,41 @@ function renderToggle(key, toggleId, labelId) { | |||||
| updateText(labelId, busy ? '...' : (on ? 'ON' : 'OFF')); | updateText(labelId, busy ? '...' : (on ? 'ON' : 'OFF')); | ||||
| } | } | ||||
| function runtimeStateClass(engineState) { | |||||
| const normalized = String(engineState || '').toLowerCase(); | |||||
| if (!normalized) { | |||||
| return 'warn'; | |||||
| } | |||||
| switch (normalized) { | |||||
| case 'faulted': | |||||
| return 'err'; | |||||
| case 'muted': | |||||
| case 'degraded': | |||||
| case 'prebuffering': | |||||
| case 'arming': | |||||
| case 'stopping': | |||||
| case 'idle': | |||||
| case 'unknown': | |||||
| return 'warn'; | |||||
| default: | |||||
| return 'good'; | |||||
| } | |||||
| } | |||||
| function updateHealth(engine, audioStream) { | function updateHealth(engine, audioStream) { | ||||
| engine = engine || {}; | engine = engine || {}; | ||||
| updateText('health-http', state.server.configOk ? 'OK' : 'OFFLINE'); | updateText('health-http', state.server.configOk ? 'OK' : 'OFFLINE'); | ||||
| $('health-http').className = 'val ' + (state.server.configOk ? 'good' : 'err'); | $('health-http').className = 'val ' + (state.server.configOk ? 'good' : 'err'); | ||||
| const runtimeState = state.server.runtimeOk ? 'OK' : 'WAITING'; | |||||
| updateText('health-runtime', runtimeState); | |||||
| $('health-runtime').className = 'val ' + (state.server.runtimeOk ? 'good' : 'warn'); | |||||
| let runtimeLabel = 'WAITING'; | |||||
| let runtimeClass = 'warn'; | |||||
| if (state.server.runtimeOk) { | |||||
| const engineStateName = String(engine.state || 'unknown'); | |||||
| runtimeLabel = engineStateName.toUpperCase(); | |||||
| runtimeClass = runtimeStateClass(engineStateName); | |||||
| } | |||||
| updateText('health-runtime', runtimeLabel); | |||||
| $('health-runtime').className = 'val ' + runtimeClass; | |||||
| const runtimeIndicator = engine.runtimeIndicator; | const runtimeIndicator = engine.runtimeIndicator; | ||||
| const indicatorLabels = { | const indicatorLabels = { | ||||