diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index ea9358e..a45a5ed 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -252,27 +252,28 @@ func (b *txBridge) StopTX() error { return b.engine.Stop(context.Background()) func (b *txBridge) TXStats() map[string]any { s := b.engine.Stats() return map[string]any{ - "state": s.State, - "chunksProduced": s.ChunksProduced, - "totalSamples": s.TotalSamples, - "underruns": s.Underruns, - "lateBuffers": s.LateBuffers, - "lastError": s.LastError, - "uptimeSeconds": s.UptimeSeconds, - "maxCycleMs": s.MaxCycleMs, - "maxGenerateMs": s.MaxGenerateMs, - "maxUpsampleMs": s.MaxUpsampleMs, - "maxWriteMs": s.MaxWriteMs, - "queue": s.Queue, - "runtimeIndicator": s.RuntimeIndicator, - "runtimeAlert": s.RuntimeAlert, - "degradedTransitions": s.DegradedTransitions, - "mutedTransitions": s.MutedTransitions, - "faultedTransitions": s.FaultedTransitions, - "faultCount": s.FaultCount, - "faultHistory": s.FaultHistory, - "transitionHistory": s.TransitionHistory, - "lastFault": s.LastFault, + "runtimeStateDurationSeconds": s.RuntimeStateDurationSeconds, + "state": s.State, + "chunksProduced": s.ChunksProduced, + "totalSamples": s.TotalSamples, + "underruns": s.Underruns, + "lateBuffers": s.LateBuffers, + "lastError": s.LastError, + "uptimeSeconds": s.UptimeSeconds, + "maxCycleMs": s.MaxCycleMs, + "maxGenerateMs": s.MaxGenerateMs, + "maxUpsampleMs": s.MaxUpsampleMs, + "maxWriteMs": s.MaxWriteMs, + "queue": s.Queue, + "runtimeIndicator": s.RuntimeIndicator, + "runtimeAlert": s.RuntimeAlert, + "degradedTransitions": s.DegradedTransitions, + "mutedTransitions": s.MutedTransitions, + "faultedTransitions": s.FaultedTransitions, + "faultCount": s.FaultCount, + "faultHistory": s.FaultHistory, + "transitionHistory": s.TransitionHistory, + "lastFault": s.LastFault, } } func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { diff --git a/docs/API.md b/docs/API.md index 4d1ea4c..ed4d8b1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -59,6 +59,7 @@ Live engine and driver telemetry. Only populated when TX is active. { "engine": { "state": "running", + "runtimeStateDurationSeconds": 12.4, "chunksProduced": 12345, "totalSamples": 1408950000, "underruns": 0, @@ -100,6 +101,8 @@ Live engine and driver telemetry. Only populated when TX is active. ``` `engine.state` spiegelt jetzt die Runtime-State-Maschine wider (idle, arming, prebuffering, running, degraded, muted, faulted, stopping) und bietet eine erste beobachtbare Basis für Fault-Transitions. +`runtimeStateDurationSeconds` sagt, wie viele Sekunden die Engine bereits im aktuellen Runtime-Zustand verweilt. So erkennt man schnell, ob `muted`/`degraded` zu lange dauern oder ob ein Übergang gerade frisch begonnen hat. + `transitionHistory` liefert die jüngsten Übergänge (from/to, severity, timestamp) damit API und UI die Runtime History synchronisieren können. --- diff --git a/internal/app/engine.go b/internal/app/engine.go index 041d0ed..a269cd8 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -69,27 +69,28 @@ func durationMs(ns uint64) float64 { } type EngineStats struct { - State string `json:"state"` - ChunksProduced uint64 `json:"chunksProduced"` - TotalSamples uint64 `json:"totalSamples"` - Underruns uint64 `json:"underruns"` - LateBuffers uint64 `json:"lateBuffers,omitempty"` - LastError string `json:"lastError,omitempty"` - UptimeSeconds float64 `json:"uptimeSeconds"` - MaxCycleMs float64 `json:"maxCycleMs,omitempty"` - MaxGenerateMs float64 `json:"maxGenerateMs,omitempty"` - MaxUpsampleMs float64 `json:"maxUpsampleMs,omitempty"` - MaxWriteMs float64 `json:"maxWriteMs,omitempty"` - Queue output.QueueStats `json:"queue"` - RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` - RuntimeAlert string `json:"runtimeAlert,omitempty"` - LastFault *FaultEvent `json:"lastFault,omitempty"` - DegradedTransitions uint64 `json:"degradedTransitions"` - MutedTransitions uint64 `json:"mutedTransitions"` - FaultedTransitions uint64 `json:"faultedTransitions"` - FaultCount uint64 `json:"faultCount"` - FaultHistory []FaultEvent `json:"faultHistory,omitempty"` - TransitionHistory []RuntimeTransition `json:"transitionHistory,omitempty"` + State string `json:"state"` + RuntimeStateDurationSeconds float64 `json:"runtimeStateDurationSeconds"` + ChunksProduced uint64 `json:"chunksProduced"` + TotalSamples uint64 `json:"totalSamples"` + Underruns uint64 `json:"underruns"` + LateBuffers uint64 `json:"lateBuffers,omitempty"` + LastError string `json:"lastError,omitempty"` + UptimeSeconds float64 `json:"uptimeSeconds"` + MaxCycleMs float64 `json:"maxCycleMs,omitempty"` + MaxGenerateMs float64 `json:"maxGenerateMs,omitempty"` + MaxUpsampleMs float64 `json:"maxUpsampleMs,omitempty"` + MaxWriteMs float64 `json:"maxWriteMs,omitempty"` + Queue output.QueueStats `json:"queue"` + RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` + RuntimeAlert string `json:"runtimeAlert,omitempty"` + LastFault *FaultEvent `json:"lastFault,omitempty"` + DegradedTransitions uint64 `json:"degradedTransitions"` + MutedTransitions uint64 `json:"mutedTransitions"` + FaultedTransitions uint64 `json:"faultedTransitions"` + FaultCount uint64 `json:"faultCount"` + FaultHistory []FaultEvent `json:"faultHistory,omitempty"` + TransitionHistory []RuntimeTransition `json:"transitionHistory,omitempty"` } type RuntimeIndicator string @@ -158,10 +159,11 @@ type Engine struct { transitionHistoryMu sync.Mutex transitionHistory []RuntimeTransition - degradedTransitions atomic.Uint64 - mutedTransitions atomic.Uint64 - faultedTransitions atomic.Uint64 - faultEvents atomic.Uint64 + degradedTransitions atomic.Uint64 + mutedTransitions atomic.Uint64 + faultedTransitions atomic.Uint64 + faultEvents atomic.Uint64 + runtimeStateEnteredAt atomic.Uint64 // Live config: pending frequency change, applied between chunks pendingFreq atomic.Pointer[float64] @@ -415,27 +417,28 @@ func (e *Engine) Stats() EngineStats { ri := runtimeIndicator(queue.Health, hasRecentLateBuffers) lastFault := e.lastFaultEvent() return EngineStats{ - State: string(e.currentRuntimeState()), - ChunksProduced: e.chunksProduced.Load(), - TotalSamples: e.totalSamples.Load(), - Underruns: e.underruns.Load(), - LateBuffers: lateBuffers, - LastError: errVal, - UptimeSeconds: uptime, - MaxCycleMs: durationMs(e.maxCycleNs.Load()), - MaxGenerateMs: durationMs(e.maxGenerateNs.Load()), - MaxUpsampleMs: durationMs(e.maxUpsampleNs.Load()), - MaxWriteMs: durationMs(e.maxWriteNs.Load()), - Queue: queue, - RuntimeIndicator: ri, - RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers), - LastFault: lastFault, - DegradedTransitions: e.degradedTransitions.Load(), - MutedTransitions: e.mutedTransitions.Load(), - FaultedTransitions: e.faultedTransitions.Load(), - FaultCount: e.faultEvents.Load(), - FaultHistory: e.FaultHistory(), - TransitionHistory: e.TransitionHistory(), + State: string(e.currentRuntimeState()), + RuntimeStateDurationSeconds: e.runtimeStateDurationSeconds(), + ChunksProduced: e.chunksProduced.Load(), + TotalSamples: e.totalSamples.Load(), + Underruns: e.underruns.Load(), + LateBuffers: lateBuffers, + LastError: errVal, + UptimeSeconds: uptime, + MaxCycleMs: durationMs(e.maxCycleNs.Load()), + MaxGenerateMs: durationMs(e.maxGenerateNs.Load()), + MaxUpsampleMs: durationMs(e.maxUpsampleNs.Load()), + MaxWriteMs: durationMs(e.maxWriteNs.Load()), + Queue: queue, + RuntimeIndicator: ri, + RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers), + LastFault: lastFault, + DegradedTransitions: e.degradedTransitions.Load(), + MutedTransitions: e.mutedTransitions.Load(), + FaultedTransitions: e.faultedTransitions.Load(), + FaultCount: e.faultEvents.Load(), + FaultHistory: e.FaultHistory(), + TransitionHistory: e.TransitionHistory(), } } @@ -613,9 +616,10 @@ func cloneFrame(src *output.CompositeFrame) *output.CompositeFrame { } func (e *Engine) setRuntimeState(state RuntimeState) { + now := time.Now() prev := e.currentRuntimeState() if prev != state { - e.recordRuntimeTransition(prev, state) + e.recordRuntimeTransition(prev, state, now) switch state { case RuntimeStateDegraded: e.degradedTransitions.Add(1) @@ -624,6 +628,9 @@ func (e *Engine) setRuntimeState(state RuntimeState) { case RuntimeStateFaulted: e.faultedTransitions.Add(1) } + e.runtimeStateEnteredAt.Store(uint64(now.UnixNano())) + } else if e.runtimeStateEnteredAt.Load() == 0 { + e.runtimeStateEnteredAt.Store(uint64(now.UnixNano())) } e.runtimeState.Store(state) } @@ -637,6 +644,13 @@ func (e *Engine) currentRuntimeState() RuntimeState { return RuntimeStateIdle } +func (e *Engine) runtimeStateDurationSeconds() float64 { + if ts := e.runtimeStateEnteredAt.Load(); ts != 0 { + return time.Since(time.Unix(0, int64(ts))).Seconds() + } + return 0 +} + func (e *Engine) hasRecentLateBuffers() bool { lateAlertAt := e.lateBufferAlertAt.Load() if lateAlertAt == 0 { @@ -670,9 +684,12 @@ func (e *Engine) TransitionHistory() []RuntimeTransition { return history } -func (e *Engine) recordRuntimeTransition(from, to RuntimeState) { +func (e *Engine) recordRuntimeTransition(from, to RuntimeState, when time.Time) { + if when.IsZero() { + when = time.Now() + } ev := RuntimeTransition{ - Time: time.Now(), + Time: when, From: from, To: to, Severity: runtimeStateSeverity(to), diff --git a/internal/control/ui.html b/internal/control/ui.html index 49709c6..754a22f 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -1160,6 +1160,7 @@ input.input-error {
HTTP
--
Runtime
--
+
State Age
--
Runtime Signal
--
Runtime Alert
--
Transitions (D/M/F)
--
@@ -2032,6 +2033,14 @@ function updateHealth(engine, audioStream) { updateText('health-runtime', runtimeLabel); $('health-runtime').className = 'val ' + runtimeClass; + const durationSeconds = Number(engine.runtimeStateDurationSeconds); + const durationLabel = Number.isFinite(durationSeconds) && durationSeconds > 0 ? fmtTime(durationSeconds) : '--'; + updateText('health-state-age', durationLabel); + const stateAgeEl = $('health-state-age'); + if (stateAgeEl) { + stateAgeEl.className = 'val ' + runtimeClass; + } + const runtimeIndicator = engine.runtimeIndicator; const indicatorLabels = { normal: 'Normal', @@ -2142,7 +2151,10 @@ function updateResetHint(engine) { } else if (stateName === 'muted' || stateName === 'degraded') { text = 'Reset Fault keeps the runtime in DEGRADED so the queue can recover before running again.'; } - hint.textContent = text; + const durationSeconds = Number(engine?.runtimeStateDurationSeconds); + const durationLabel = Number.isFinite(durationSeconds) && durationSeconds > 0 ? fmtTime(durationSeconds) : null; + const ageHint = durationLabel ? ` State age ${durationLabel}.` : ''; + hint.textContent = text + ageHint; } function updateMeters(engine, driver, audioStream) {