diff --git a/docs/pro-runtime-hardening-workboard.md b/docs/pro-runtime-hardening-workboard.md index 7be3c93..8fb6a52 100644 --- a/docs/pro-runtime-hardening-workboard.md +++ b/docs/pro-runtime-hardening-workboard.md @@ -282,6 +282,7 @@ Einführen eines klaren Betriebsmodells mit Fault-, Recovery- und Muted-Zuständ - Control-plane UI now also offers a Danger Zone `Reset Fault` button that calls the same endpoint so operators can acknowledge faults from the dashboard. - Control-plane UI now posts an ops toast/log entry whenever the runtime state shifts so escalations and manual acknowledgements are immediately visible. +- Control-plane UI now keeps a compact Transition History panel beside the Fault History so operators can see recent runtime shifts without scrolling the activity log. ## Zielzustände laut Konzept @@ -334,6 +335,7 @@ Einführen eines klaren Betriebsmodells mit Fault-, Recovery- und Muted-Zuständ | 2026-04-05 | Manual fault reset endpoint | Added `POST /runtime/fault/reset` so operators can acknowledge `faulted` before the supervisor re-enters recovery. | | 2026-04-05 | Fault-reset UI shortcut | Danger Zone now hosts a Reset Fault button wired to `/runtime/fault/reset` so operators get an in-app acknowledgement path without manual HTTP calls. | | 2026-04-06 | Runtime transition visibility cue | Control UI now posts toast/log entries for runtime state shifts so ops instantly sees escalations and manual reset acknowledgements. | +| 2026-04-06 | Transition history panel | Added a compact Transition History panel next to the Fault History so the last few runtime state shifts stay visible even when the activity log is full. | ## WS-02 Verifikation | Datum | Fokus | Ergebnis | diff --git a/internal/control/ui.html b/internal/control/ui.html index ba94f0c..f3aeb7b 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -809,6 +809,45 @@ input.input-error { color: var(--text-muted); font-size: 11px; } +.transition-history { + margin-top: 12px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + font-size: 11px; + max-height: 180px; + overflow-y: auto; + line-height: 1.3; +} +.transition-history-entry { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 4px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.transition-history-entry:last-child { + border-bottom: none; +} +.transition-history-entry .transition-history-time { + color: var(--text-dim); +} +.transition-history-entry.good { color: var(--green); } +.transition-history-entry.warn { color: var(--amber); } +.transition-history-entry.err { color: var(--accent); } +.transition-history-entry.info { color: var(--text); } +.transition-history-desc { + font-size: 10px; + flex: 1; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.transition-history-empty { + padding: 6px 0; + color: var(--text-muted); + font-size: 11px; +} .section-note.reset-hint { font-size: 11px; color: var(--text-dim); @@ -1174,6 +1213,20 @@ input.input-error { +
+
+

Transition History

+
recent state shifts
+ +
+
+
Keeps runtime escalations visible without scrolling the activity log.
+
+
No transitions yet.
+
+
+
+

Fault History

@@ -1215,6 +1268,7 @@ const configPollMs = 8000; const mobileMq = window.matchMedia('(max-width: 640px)'); const freqPresetValues = [87.6, 94.5, 99.5, 100.0, 107.9]; const sparkHistoryLimit = 40; +const transitionHistoryLimit = 6; const state = { server: { @@ -1248,6 +1302,7 @@ const state = { underruns: [], tx: [], }, + runtimeTransitions: [], freqPresetIndex: 0, }; @@ -1425,6 +1480,52 @@ function pushHistory(runtime) { pushChart(state.charts.tx, txState === 'running' ? 1 : state.txBusy ? 0.55 : 0.05); } +function pushTransitionHistory(from, to, severity) { + if (!from || !to) return; + const entry = { + from: normalizeRuntimeState(from), + to: normalizeRuntimeState(to), + severity: severity || 'info', + time: nowTs(), + }; + state.runtimeTransitions.unshift(entry); + if (state.runtimeTransitions.length > transitionHistoryLimit) { + state.runtimeTransitions.splice(transitionHistoryLimit); + } + updateTransitionHistory(); +} + +function transitionSeverityClass(severity) { + switch (String(severity || '').toLowerCase()) { + case 'err': + return 'err'; + case 'warn': + return 'warn'; + case 'ok': + case 'good': + return 'good'; + default: + return 'info'; + } +} + +function updateTransitionHistory() { + const container = $('transition-history'); + if (!container) return; + if (!state.runtimeTransitions.length) { + container.innerHTML = '
No transitions yet.
'; + return; + } + const rows = state.runtimeTransitions.map((entry) => { + const when = entry?.time ? new Date(entry.time) : null; + const timeLabel = when && !Number.isNaN(when.getTime()) ? when.toLocaleTimeString() : '--:--'; + const desc = `${entry.from.toUpperCase()} → ${entry.to.toUpperCase()}`; + const severityClass = transitionSeverityClass(entry.severity); + return `
${timeLabel}${desc}
`; + }); + container.innerHTML = rows.join(''); +} + function pushChart(arr, value) { arr.push(Number.isFinite(value) ? value : 0); if (arr.length > sparkHistoryLimit) arr.splice(0, arr.length - sparkHistoryLimit); @@ -1813,6 +1914,7 @@ function render() { updateHealth(engine, audioStream); updateFaultHistory(engine); + updateTransitionHistory(); updateResetHint(engine); updateMeters(engine, driver, audioStream); drawSparkline('spark-audio', state.charts.audio, 'good', 1); @@ -1879,8 +1981,9 @@ function notifyRuntimeTransition(engine) { const prev = state.lastRuntimeState; state.lastRuntimeState = next; if (!prev || prev === next) return; - const message = `Runtime ${prev.toUpperCase()} → ${next.toUpperCase()}`; const severity = runtimeStateSeverity(next); + pushTransitionHistory(prev, next, severity); + const message = `Runtime ${prev.toUpperCase()} → ${next.toUpperCase()}`; const logLevel = severity === 'err' ? 'err' : (severity === 'warn' ? 'warn' : 'info'); toast(message, severity); log(message, logLevel);