Skip to content

Commit cea0710

Browse files
fix(live): paused page loads nothing; overlay synchronous; log horizontal scroll
Three root-cause fixes for the /live stopped/idle UX, plus the log-wrap reversal. 1) Overlay no longer flashes the live shell on load. The overlay's visibility was decided only inside the async checkBroadcastState() fetch, so a stopped page showed the empty live shell until the fetch returned, then dropped the overlay on top. Now the overlay ships SHOWN in the markup and the decision is made SYNCHRONOUSLY from the last-known snapshot (_cachedRunActive, no network) before any await: a stopped page is covered from the first paint; an operator's mid-run refresh reveals the cached work instantly. 2) An idle/paused page no longer LOADS data behind the overlay (root cause, server-side). The public surfaces (SSE init, /api/public/logs, /api/public/live-state) shipped the hydrated log backlog + last run's results whenever broadcast was on — even with no run in flight (e.g. right after boot). New publicRunActive() gates every work payload: logs/results are sent ONLY during an active run (continuous loop running, an in-flight batch, or state.status running/paused*). Removing the overlay on an idle page now reveals a genuinely empty page, not a leaked log + TESTED count. 3) Live log lines no longer wrap — the log panel scrolls horizontally instead, on BOTH /live and admin (per request). Each entry is white-space:nowrap + width:max-content; min-width:100%; the panel gets overflow-x:auto. Tests: adds test/public-run-active.test.js (11) and extends test/live-load-gating.test.js with _cachedRunActive (now 18). Two adversarial fresh-eyes reviews returned SHIP. Full npm test green.
1 parent b49f88a commit cea0710

6 files changed

Lines changed: 208 additions & 26 deletions

File tree

admin.html

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,7 @@
711711
flex: 1 1 auto;
712712
min-height: 0;
713713
overflow-y: auto;
714+
overflow-x: auto;
714715
padding: 12px;
715716
display: flex;
716717
flex-direction: column;
@@ -726,10 +727,11 @@
726727
border-left: 2px solid var(--text-dim);
727728
color: var(--text);
728729
animation: slideIn 0.2s ease;
729-
/* Wrap long lines to the next line instead of overflowing — long
730-
unbroken tokens (addresses, base64 memos, URLs) break too. */
731-
overflow-wrap: anywhere;
732-
word-break: break-word;
730+
/* Keep each line on ONE row; the log panel scrolls horizontally for long
731+
lines (addresses, base64 memos, URLs) rather than wrapping them. */
732+
white-space: nowrap;
733+
width: max-content;
734+
min-width: 100%;
733735
}
734736

735737
.log-warn { border-left-color: var(--text-muted); color: var(--text-dim); }

live.html

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -436,18 +436,19 @@
436436
}
437437
.logs {
438438
flex: 1 1 auto; min-height: 0;
439-
overflow-y: auto; padding: 12px;
439+
overflow-y: auto; overflow-x: auto; padding: 12px;
440440
display: flex; flex-direction: column; gap: 4px;
441441
font-family: var(--font-mono); font-size: 11px;
442442
}
443443
.log-entry {
444444
padding: 5px 10px; background: var(--bg-input);
445445
border-radius: 4px; border-left: 2px solid var(--text-dim);
446446
color: var(--text); animation: slideIn 0.2s ease;
447-
white-space: normal; overflow-wrap: anywhere;
448-
display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px;
447+
white-space: nowrap;
448+
display: flex; align-items: baseline; gap: 8px;
449449
font-variant-numeric: tabular-nums;
450450
flex: 0 0 auto;
451+
width: max-content; min-width: 100%;
451452
}
452453
.log-entry.log-ok { border-left-color: var(--green); }
453454
.log-entry.log-err { border-left-color: var(--red); }
@@ -498,7 +499,7 @@
498499
color: var(--red); font-weight: 600;
499500
flex-shrink: 0;
500501
}
501-
.log-entry .log-msg { color: var(--text); flex: 1 1 auto; min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
502+
.log-entry .log-msg { color: var(--text); flex: 0 0 auto; white-space: nowrap; }
502503
.log-entry .log-copy {
503504
margin-left: auto; flex-shrink: 0;
504505
background: transparent; border: 1px solid var(--border);
@@ -637,7 +638,7 @@
637638

638639
<body class="boot-pending">
639640
<!-- Testing Paused overlay -->
640-
<div class="paused-overlay" id="pausedOverlay" aria-hidden="true" role="status" aria-live="polite">
641+
<div class="paused-overlay show" id="pausedOverlay" aria-hidden="false" role="status" aria-live="polite">
641642
<div class="paused-stack">
642643
<div class="paused-logo-wrap">
643644
<img src="/logo.jpg" alt="Sentinel" class="paused-logo">
@@ -932,19 +933,34 @@ <h1 class="live-page-title">
932933
if (_liveResultsNext) _liveResultsNext.addEventListener('click', () => liveGoPage(1));
933934
const _liveSearchInput = document.getElementById('liveSearchInput');
934935
if (_liveSearchInput) _liveSearchInput.addEventListener('input', (e) => setLiveSearch(e.target.value));
935-
// Resolve broadcast/run state BEFORE painting ANY data. When stopped
936-
// (broadcast off) checkBroadcastState raises the opaque paused overlay
937-
// immediately and we deliberately skip every paint path below — the page
938-
// shows ONLY the overlay, never a flash of the last dataset behind it.
939-
// When live we paint the cached snapshot, seed the real data, and show the
940-
// work; the overlay never appears for an active run because
941-
// applyPauseFromState stays gated until the authoritative status lands.
942-
await checkBroadcastState();
943-
if (_broadcastLive) {
944-
// Cached snapshot first for an instant refresh, then authoritative REST.
936+
// Decide the overlay SYNCHRONOUSLY from the last-known snapshot — BEFORE the
937+
// async broadcast check — so the page never flashes the live shell while
938+
// that fetch is in flight. The overlay ships SHOWN in the markup, so a
939+
// stopped page stays fully covered from the very first paint. Only when the
940+
// cached snapshot was an active run (operator refreshing mid-run) do we
941+
// paint it and reveal the work up-front; the authoritative check below
942+
// confirms or corrects.
943+
let _paintedFromCache = false;
944+
if (_cachedRunActive()) {
945945
rehydrateFromCache();
946946
renderTable();
947947
renderLiveStats();
948+
showPaused(false);
949+
_paintedFromCache = true;
950+
}
951+
// Resolve broadcast/run state. When stopped (broadcast off) the overlay
952+
// stays up and we skip every paint path below — the page shows ONLY the
953+
// overlay, never a flash of the last dataset behind it. When live we paint
954+
// the cached snapshot, seed the real data, and show the work; the overlay
955+
// is dropped once the authoritative running status lands.
956+
await checkBroadcastState();
957+
if (_broadcastLive) {
958+
if (!_paintedFromCache) {
959+
// Cached snapshot first for an instant refresh, then authoritative REST.
960+
rehydrateFromCache();
961+
renderTable();
962+
renderLiveStats();
963+
}
948964
// Seeds full live state + results so refresh paints the same dashboard
949965
// the operator sees, even before the SSE init message lands.
950966
await seedLiveStateFromRest();
@@ -1941,6 +1957,21 @@ <h1 class="live-page-title">
19411957
} catch {}
19421958
}, 250);
19431959
}
1960+
// Synchronous (no network) read of the persisted snapshot: did the last
1961+
// session leave an ACTIVE, broadcasting run? Used at load to decide the
1962+
// overlay before the async broadcast check, so a stopped page never flashes
1963+
// the live shell and an operator's mid-run refresh reveals the work instantly.
1964+
function _cachedRunActive() {
1965+
try {
1966+
const raw = localStorage.getItem(LIVE_CACHE_KEY);
1967+
if (!raw) return false;
1968+
const snap = JSON.parse(raw);
1969+
if (!snap || !snap.ts || (Date.now() - snap.ts) > LIVE_CACHE_TTL_MS) return false;
1970+
if (!snap.broadcastLive) return false;
1971+
const s = snap.state && snap.state.status;
1972+
return s === 'running' || s === 'paused' || !!(snap.cb && snap.cb.batchId);
1973+
} catch { return false; }
1974+
}
19441975
function rehydrateFromCache() {
19451976
try {
19461977
const raw = localStorage.getItem(LIVE_CACHE_KEY);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"postinstall": "node scripts/postinstall.js",
3939
"start": "node server.js",
4040
"audit": "node server.js",
41-
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js",
41+
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js",
4242
"test:ui": "node test/ui.smoke.test.js",
4343
"test:integration": "node test/continuous.audit-integration.test.js && node test/continuous.live-db-write.test.js && node test/node-detail.smoke.test.js && node test/resume-results-seed.test.js",
4444
"verify:prod-db": "node scripts/verify-prod-db.mjs",

server.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,20 @@ function publicLogBuffer() {
313313
return logBuffer.filter(e => classifyLogCategory(e.msg) === 'node');
314314
}
315315

316+
// Is there an ACTIVE run worth showing to /live right now? Public live surfaces
317+
// (SSE init, /logs, /live-state) must return EMPTY work payloads when no run is
318+
// in flight — otherwise a paused/idle page (e.g. right after boot, when
319+
// logBuffer is hydrated from results/audit-*.log) ships the last run's log
320+
// backlog + results behind the "Testing Has Been Paused" overlay. The overlay
321+
// is opaque so it's not visible, but the page still LOADS that data — which is
322+
// exactly what we don't want. Gate the payload at the source instead.
323+
function publicRunActive() {
324+
try { if (continuous.status()?.running) return true; } catch (e) { console.error('[publicRunActive] continuous.status failed:', e.message); }
325+
try { if (getActiveBatch()) return true; } catch (e) { console.error('[publicRunActive] getActiveBatch failed:', e.message); }
326+
const st = state && state.status;
327+
return st === 'running' || (typeof st === 'string' && st.indexOf('paused') === 0);
328+
}
329+
316330
// EVENTS persist to a file (separate from per-run runs/test-NNN/audit.log), with
317331
// a simple 1-file rotation at ~2MB so it can't grow unbounded.
318332
const EVENTS_LOG_FILE = path.join(__dirname, 'results', 'events.log');
@@ -2015,17 +2029,23 @@ app.get('/api/public/events', attachAdminFlag, rlPublicSse, (req, res) => {
20152029
// operator-internal fields). Empty when broadcastLive is off (unless admin).
20162030
const isAdminViewer = req.admin === true;
20172031
const liveOn = !!state.broadcastLive || isAdminViewer;
2032+
// Only ship the live work payload (log backlog + per-node results) when a run
2033+
// is actually in flight. With broadcast on but idle (e.g. just-booted server
2034+
// with a hydrated logBuffer, or a finished run), /live must paint NOTHING
2035+
// behind the paused overlay — it should show the overlay only, not load data.
2036+
const workOn = liveOn && publicRunActive();
20182037
const initState = liveOn ? sanitizePublicState(state) : {};
2019-
const initResults = liveOn ? getResults().map(sanitizePublicResult).filter(Boolean) : [];
2038+
const initResults = workOn ? getResults().map(sanitizePublicResult).filter(Boolean) : [];
20202039
send({
20212040
type: 'init',
20222041
status: { running: s.running, iteration: s.iteration, mode: s.mode, startedAt: s.startedAt, uptime: s.uptime },
20232042
batchId: activeBatchId,
20242043
snapshotSize: activeSnapshotSize,
20252044
batchMode: activeBatchMode,
2026-
// Persisted log backlog so /live shows full history on refresh, not a blank panel.
2027-
// logBuffer is populated from results/audit-*.log on server boot and updated live.
2028-
logs: liveOn ? publicLogBuffer() : [],
2045+
// Persisted log backlog so /live shows full history on refresh, not a blank
2046+
// panel — but ONLY during an active run. logBuffer is hydrated from
2047+
// results/audit-*.log on boot, so an idle page would otherwise leak it.
2048+
logs: workOn ? publicLogBuffer() : [],
20292049
state: initState,
20302050
results: initResults,
20312051
// Report effective-live so the admin's own /live page flips into live mode
@@ -2063,6 +2083,9 @@ app.get('/api/public/logs', attachAdminFlag, rlPublicRead, (req, res) => {
20632083
// toggling broadcast. Public visitors still gated by broadcastLive.
20642084
const showLive = state.broadcastLive || req.admin === true;
20652085
if (!showLive) return res.json({ logs: [], broadcastLive: false });
2086+
// No active run → no backlog. A paused/idle /live must stay blank behind the
2087+
// overlay, not replay the last run's log buffer.
2088+
if (!publicRunActive()) return res.json({ logs: [], broadcastLive: true });
20662089
// Report effective-live so admin viewers get the live-mode UI on /live.
20672090
res.json({ logs: publicLogBuffer(), broadcastLive: true });
20682091
});
@@ -2095,11 +2118,14 @@ app.get('/api/public/live-state', attachAdminFlag, rlPublicRead, (req, res) => {
20952118
activeSnapshotSize = ab.batch.snapshot_size;
20962119
}
20972120
} catch (_) {}
2121+
// Results only when a run is in flight — an idle page must seed an empty
2122+
// table behind the paused overlay, not the last run's rows.
2123+
const workOn = publicRunActive();
20982124
res.json({
20992125
// Report effective-live so admin viewers get the live-mode UI on /live.
21002126
broadcastLive: true,
21012127
state: sanitizePublicState(state),
2102-
results: getResults().map(sanitizePublicResult).filter(Boolean),
2128+
results: workOn ? getResults().map(sanitizePublicResult).filter(Boolean) : [],
21032129
activeBatchId,
21042130
snapshotSize: activeSnapshotSize,
21052131
});

test/live-load-gating.test.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ function extractFn(src, name) {
4848
return src.slice(m.index, j);
4949
}
5050

51-
const extracted = ['rehydrateFromCache', 'seedLiveStateFromRest']
51+
const extracted = ['_cachedRunActive', 'rehydrateFromCache', 'seedLiveStateFromRest']
5252
.map(n => extractFn(html, n)).join('\n\n');
5353

5454
// ─── Fake environment ────────────────────────────────────────────────────────
@@ -111,6 +111,27 @@ const idleSnap = {
111111

112112
console.log('\n/live load-sequence gating — stopped-flash regression\n');
113113

114+
// ─── 0. _cachedRunActive: synchronous overlay decision (no network) ──────────
115+
console.log('[0] _cachedRunActive drives the synchronous overlay decision');
116+
function cachedActive(snap) {
117+
const sb = makeSandbox();
118+
sb.localStorage = fakeLocalStorage(snap);
119+
return vm.runInContext('_cachedRunActive()', sb);
120+
}
121+
ok(cachedActive(activeSnap('running')) === true,
122+
'broadcasting + running cache → active (reveal work synchronously)');
123+
ok(cachedActive({ ts: NOW, broadcastLive: true, state: {}, cb: { batchId: 9 }, results: [] }) === true,
124+
'broadcasting + live batchId → active');
125+
ok(cachedActive(idleSnap) === false,
126+
'broadcasting + done status → NOT active (overlay stays up)');
127+
ok(cachedActive({ ...activeSnap('running'), broadcastLive: false }) === false,
128+
'last session not broadcasting → NOT active even if status running');
129+
ok(cachedActive(null) === false, 'no cache → not active (default to overlay)');
130+
{
131+
const stale = activeSnap('running'); stale.ts = NOW - (2 * 60 * 60 * 1000);
132+
ok(cachedActive(stale) === false, 'expired cache → not active');
133+
}
134+
114135
// ─── 1. rehydrateFromCache: active cache repaints, idle cache does NOT ────────
115136
console.log('[1] rehydrateFromCache gates row paint on a cached ACTIVE run');
116137
{

test/public-run-active.test.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* publicRunActive() — idle /live must not load the last run's data
3+
*
4+
* Bug: the public live surfaces (SSE init, /api/public/logs, /api/public/live-state)
5+
* shipped the log backlog + per-node results whenever broadcastLive was on —
6+
* even with NO run in flight. Right after a server boot, logBuffer is hydrated
7+
* from results/audit-*.log, so an idle /live page LOADED that backlog (and the
8+
* last run's results) behind the opaque "Testing Has Been Paused" overlay.
9+
* Removing the overlay in devtools revealed a live log + TESTED count but no
10+
* rows — the page was loading data it should not.
11+
*
12+
* Fix: gate every public work payload on publicRunActive() — true only when a
13+
* run is genuinely in flight (continuous loop running, OR an active batch row,
14+
* OR state.status running/paused*). This test runs the REAL publicRunActive
15+
* extracted from server.js against stubbed continuous/getActiveBatch/state.
16+
*
17+
* Run: node test/public-run-active.test.js
18+
*/
19+
20+
import { readFileSync } from 'node:fs';
21+
import { fileURLToPath } from 'node:url';
22+
import { dirname, join } from 'node:path';
23+
import vm from 'node:vm';
24+
25+
const out = { pass: 0, fail: 0, errors: [] };
26+
function ok(cond, name) {
27+
if (cond) { out.pass++; console.log(` PASS ${name}`); }
28+
else { out.fail++; out.errors.push(name); console.log(` FAIL ${name}`); }
29+
}
30+
31+
const __dirname = dirname(fileURLToPath(import.meta.url));
32+
const src = readFileSync(join(__dirname, '..', 'server.js'), 'utf8');
33+
34+
function extractFn(s, name) {
35+
const m = new RegExp(`function\\s+${name}\\s*\\(`).exec(s);
36+
if (!m) throw new Error(`function ${name} not found in server.js`);
37+
let depth = 0, started = false, j = m.index;
38+
for (; j < s.length; j++) {
39+
const c = s[j];
40+
if (c === '{') { depth++; started = true; }
41+
else if (c === '}') { depth--; if (started && depth === 0) { j++; break; } }
42+
}
43+
return s.slice(m.index, j);
44+
}
45+
46+
const extracted = extractFn(src, 'publicRunActive');
47+
48+
function evalActive({ running = false, activeBatch = null, status = 'idle' } = {}) {
49+
const sandbox = {
50+
continuous: { status: () => ({ running }) },
51+
getActiveBatch: () => activeBatch,
52+
state: { status },
53+
console,
54+
};
55+
vm.createContext(sandbox);
56+
vm.runInContext(extracted, sandbox);
57+
return vm.runInContext('publicRunActive()', sandbox);
58+
}
59+
60+
console.log('\npublicRunActive() — idle /live must not load data\n');
61+
62+
// ─── Active signals ──────────────────────────────────────────────────────────
63+
console.log('[1] any active signal → true');
64+
ok(evalActive({ running: true, status: 'idle' }) === true,
65+
'continuous loop running → active');
66+
ok(evalActive({ activeBatch: { batch: { id: 9 } }, status: 'idle' }) === true,
67+
'an in-flight batch row → active (covers direct p2p / sub-plan / test runs)');
68+
ok(evalActive({ status: 'running' }) === true,
69+
'state.status running → active');
70+
ok(evalActive({ status: 'paused_balance' }) === true,
71+
'state.status paused_balance (mid-run pause) → active');
72+
ok(evalActive({ status: 'paused_internet' }) === true,
73+
'state.status paused_internet (mid-run pause) → active');
74+
75+
// ─── Idle / terminal → NOT active ────────────────────────────────────────────
76+
console.log('[2] idle / terminal → false (paused page loads nothing)');
77+
ok(evalActive({ status: 'idle' }) === false, 'fresh boot, idle → not active');
78+
ok(evalActive({ status: 'done' }) === false, 'finished run (done) → not active');
79+
ok(evalActive({ status: 'stopped' }) === false, 'stopped run → not active');
80+
ok(evalActive({ status: 'error' }) === false, 'errored run → not active');
81+
ok(evalActive({ running: false, activeBatch: null, status: undefined }) === false,
82+
'no signals at all → not active');
83+
84+
// ─── Robust to throwing dependencies ─────────────────────────────────────────
85+
console.log('[3] throwing continuous/getActiveBatch does not crash the gate');
86+
{
87+
const sandbox = {
88+
continuous: { status: () => { throw new Error('boom'); } },
89+
getActiveBatch: () => { throw new Error('boom'); },
90+
state: { status: 'idle' },
91+
console: { error() {} },
92+
};
93+
vm.createContext(sandbox);
94+
vm.runInContext(extracted, sandbox);
95+
ok(vm.runInContext('publicRunActive()', sandbox) === false,
96+
'both deps throw + idle status → false, no throw');
97+
}
98+
99+
console.log(`\n${'='.repeat(60)}\nRESULTS: ${out.pass} passed, ${out.fail} failed (${out.pass + out.fail} total)`);
100+
if (out.errors.length) for (const e of out.errors) console.log(` FAIL: ${e}`);
101+
console.log('='.repeat(60));
102+
process.exit(out.fail ? 1 : 0);

0 commit comments

Comments
 (0)