Skip to content

Commit aa7aa8e

Browse files
feat(logs): newest-first log order (latest on top, older below) — admin + live
Logs now show the latest line at the TOP with older lines moving down, on both the public /live page and admin. Implemented by prepending instead of appending: - appendLog: insertBefore(div, firstChild) → newest at top; scrollTop=0 keeps it in view; trim removes lastChild (oldest) off the bottom. - live seedLogBacklog: unchanged loop (oldest→newest) now lands newest on top via the prepend; final scroll to top. - admin appendLogBatch: build the fragment newest→oldest and prepend the whole batch; trim the oldest off the tail. - admin setLogFilter: scroll to top after filtering. Optimized: writing scrollTop=0 (instead of reading scrollHeight) avoids the forced synchronous layout reflow the old append path triggered per line — the batch path stays single-reflow and O(cap). Tests: admin-log-batch updated for newest-first (fake DOM insertBefore now expands fragments + exposes lastChild; new assertions lock newest-at-head for the batch, single-line, and trim paths). Full suite green.
1 parent 1aed0ae commit aa7aa8e

3 files changed

Lines changed: 57 additions & 27 deletions

File tree

admin.html

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2101,7 +2101,7 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
21012101
for (const row of body.children) {
21022102
row.style.display = _logEntryMatches(row, f) ? '' : 'none';
21032103
}
2104-
body.scrollTop = body.scrollHeight;
2104+
body.scrollTop = 0;
21052105
}
21062106

21072107
function _logEntryMatches(row, f) {
@@ -2161,9 +2161,12 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
21612161
const body = document.getElementById('logBody');
21622162
const { msg, ts } = _logEntryFields(entry);
21632163
const tsMs = (entry && typeof entry === 'object') ? ts : Date.now();
2164-
body.appendChild(_buildLogEntry(msg, cat, tsMs));
2165-
body.scrollTop = body.scrollHeight;
2166-
if (body.children.length > LOG_DOM_MAX) body.removeChild(body.firstChild);
2164+
// Newest-first: prepend at the TOP, scroll to the top to keep it in view,
2165+
// and trim the OLDEST off the bottom. Writing scrollTop=0 (vs reading
2166+
// scrollHeight) avoids a forced synchronous layout — one cheap reflow.
2167+
body.insertBefore(_buildLogEntry(msg, cat, tsMs), body.firstChild);
2168+
body.scrollTop = 0;
2169+
if (body.children.length > LOG_DOM_MAX) body.removeChild(body.lastChild);
21672170
}
21682171

21692172
// Batch-replay the server's init log buffer (up to LOG_BUFFER_MAX=5000 lines)
@@ -2179,14 +2182,18 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
21792182
const body = document.getElementById('logBody');
21802183
const tail = entries.length > LOG_DOM_MAX ? entries.slice(-LOG_DOM_MAX) : entries;
21812184
const frag = document.createDocumentFragment();
2182-
// Replay uses each line's STORED emission ts (null → blank) — never now.
2183-
for (const e of tail) {
2184-
const { msg, ts } = _logEntryFields(e);
2185+
// Newest-first order: walk the tail newest→oldest so the fragment's first
2186+
// child is the newest line. Replay uses each line's STORED emission ts
2187+
// (null → blank) — never now.
2188+
for (let i = tail.length - 1; i >= 0; i--) {
2189+
const { msg, ts } = _logEntryFields(tail[i]);
21852190
frag.appendChild(_buildLogEntry(msg, undefined, ts));
21862191
}
2187-
body.appendChild(frag);
2188-
body.scrollTop = body.scrollHeight;
2189-
while (body.children.length > LOG_DOM_MAX) body.removeChild(body.firstChild);
2192+
// Prepend the whole batch above anything already shown, scroll to the top,
2193+
// and trim the OLDEST off the bottom.
2194+
body.insertBefore(frag, body.firstChild);
2195+
body.scrollTop = 0;
2196+
while (body.children.length > LOG_DOM_MAX) body.removeChild(body.lastChild);
21902197
}
21912198

21922199
// ─── Test Run Management ────────────────────────────────────────────────

live.html

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -923,10 +923,10 @@ <h1 class="live-page-title">
923923
// changes — the SSE init only fires on the initial connection.
924924
// Replay a persisted log backlog into the panel without freezing the page.
925925
// The panel only ever keeps the last MAX_LOG entries, so replaying more is
926-
// wasted work — cap to the tail. appendLog forces a layout reflow per call
927-
// (scrollTop=scrollHeight); _logSeeding suppresses that during the bulk
928-
// loop and we scroll once at the end. The server buffer is LOG_BUFFER_MAX
929-
// (5000) lines, so the naive replay was up to ~5000 synchronous reflows.
926+
// wasted work — cap to the tail. _logSeeding suppresses appendLog's per-line
927+
// scroll during the bulk loop and we scroll once at the end. appendLog
928+
// prepends (newest on top), so replaying the tail oldest→newest leaves the
929+
// newest line at the top. The server buffer is LOG_BUFFER_MAX (5000) lines.
930930
function seedLogBacklog(lines) {
931931
const body = document.getElementById('logBody');
932932
if (!body || body.children.length !== 0) return;
@@ -938,7 +938,7 @@ <h1 class="live-page-title">
938938
} finally {
939939
_logSeeding = false;
940940
}
941-
body.scrollTop = body.scrollHeight;
941+
body.scrollTop = 0;
942942
}
943943

944944
async function seedLogsFromRest() {
@@ -1128,8 +1128,8 @@ <h1 class="live-page-title">
11281128
const LIVE_PAGE_SIZE = 50;
11291129
let _livePage = 1;
11301130
const MAX_LOG = 400;
1131-
// Suppresses appendLog's per-line scrollTop=scrollHeight (a forced layout
1132-
// reflow) during bulk backlog replay — see seedLogBacklog().
1131+
// Suppresses appendLog's per-line scroll reset during bulk backlog replay,
1132+
// so we scroll once at the end — see seedLogBacklog().
11331133
let _logSeeding = false;
11341134

11351135
// ─── Helpers ───
@@ -1547,12 +1547,14 @@ <h1 class="live-page-title">
15471547
}
15481548

15491549
div.innerHTML = parts.join('');
1550-
body.appendChild(div);
1550+
// Newest-first: prepend each new line at the TOP, scroll to the top to
1551+
// keep it in view, and trim the OLDEST off the bottom.
1552+
body.insertBefore(div, body.firstChild);
15511553
// During bulk backlog replay we scroll once at the end (seedLogBacklog) —
1552-
// reading scrollHeight here per line forces a synchronous layout reflow,
1553-
// which froze the page when replaying thousands of buffered lines.
1554-
if (!_logSeeding) body.scrollTop = body.scrollHeight;
1555-
if (body.children.length > MAX_LOG) body.removeChild(body.firstChild);
1554+
// reading scroll positions here per line forces a synchronous layout
1555+
// reflow, which froze the page when replaying thousands of buffered lines.
1556+
if (!_logSeeding) body.scrollTop = 0;
1557+
if (body.children.length > MAX_LOG) body.removeChild(body.lastChild);
15561558
}
15571559

15581560
// ─── Toast / status ───

test/admin-log-batch.test.js

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
* and inserts + scrolls exactly once. This test runs the REAL functions from
1212
* admin.html against a fake DOM and asserts the work is O(cap), single-reflow.
1313
*
14+
* Logs are newest-first: appendLog prepends and trims the oldest off the tail;
15+
* appendLogBatch builds the fragment newest→oldest and prepends it. The order
16+
* assertions below lock that (newest at the head/top).
17+
*
1418
* Run: node test/admin-log-batch.test.js
1519
*/
1620

@@ -63,12 +67,18 @@ function makeNode(tag) {
6367
__isFrag: tag === '#fragment',
6468
get children() { return this._children; },
6569
get firstChild() { return this._children[0]; },
70+
get lastChild() { return this._children[this._children.length - 1]; },
6671
appendChild(c) {
6772
if (c && c.__isFrag) { for (const cc of c._children) this._children.push(cc); c._children = []; }
6873
else this._children.push(c);
6974
return c;
7075
},
71-
insertBefore(c) { this._children.unshift(c); return c; },
76+
// Prepend (newest-first). Expand a fragment at the front preserving order.
77+
insertBefore(c) {
78+
if (c && c.__isFrag) { this._children.unshift(...c._children); c._children = []; }
79+
else this._children.unshift(c);
80+
return c;
81+
},
7282
removeChild(c) { const i = this._children.indexOf(c); if (i >= 0) this._children.splice(i, 1); return c; },
7383
};
7484
// Count writes to scrollTop — each write is a forced reflow in the browser.
@@ -115,10 +125,11 @@ ok(createElementCount === LOG_DOM_MAX,
115125
`builds only ${LOG_DOM_MAX} nodes, not 5000 (got ${createElementCount})`);
116126
ok(createFragCount === 1, `uses one DocumentFragment (got ${createFragCount})`);
117127
// Only the LAST 500 lines are kept (matches the cap the live path enforces).
118-
ok(logBody.children[logBody.children.length - 1].innerHTML.includes('line 4999'),
119-
'keeps the newest line (4999) at the tail');
120-
ok(logBody.children[0].innerHTML.includes('line 4500'),
121-
'oldest kept line is 4500 (last 500 of 5000)');
128+
// Newest-first: the newest line sits at the HEAD, oldest-kept at the tail.
129+
ok(logBody.children[0].innerHTML.includes('line 4999'),
130+
'keeps the newest line (4999) at the head (top)');
131+
ok(logBody.children[logBody.children.length - 1].innerHTML.includes('line 4500'),
132+
'oldest kept line is 4500 at the tail (last 500 of 5000)');
122133

123134
// ─── 2. Small buffer: appends all, still single reflow ───────────────────────
124135
console.log('[2] appendLogBatch(small buffer) appends all, single reflow');
@@ -127,6 +138,10 @@ const small = ['alpha', 'bravo', 'charlie'];
127138
vm.runInContext('appendLogBatch(globalThis.__small)', Object.assign(sandbox, { __small: small }));
128139
ok(logBody.children.length === 3, `appended all 3 (got ${logBody.children.length})`);
129140
ok(logBody._scrollWrites === 1, `single reflow (got ${logBody._scrollWrites})`);
141+
// Newest-first: last-in (charlie) at the head, first-in (alpha) at the tail.
142+
ok(logBody.children[0].innerHTML.includes('charlie') &&
143+
logBody.children[2].innerHTML.includes('alpha'),
144+
'batch is newest-first (charlie at top, alpha at bottom)');
130145

131146
// ─── 3. Guards ───────────────────────────────────────────────────────────────
132147
console.log('[3] guards: empty / non-array are no-ops');
@@ -143,6 +158,12 @@ for (let i = 0; i < 600; i++) {
143158
}
144159
ok(logBody.children.length === LOG_DOM_MAX,
145160
`single-line appends trim to ${LOG_DOM_MAX} (got ${logBody.children.length})`);
161+
// Newest-first: the last line appended (live 599) is at the head; the oldest
162+
// surviving line (live 100, after trimming 0–99) is at the tail.
163+
ok(logBody.children[0].innerHTML.includes('live 599'),
164+
'newest single-line append is at the head (top)');
165+
ok(logBody.children[logBody.children.length - 1].innerHTML.includes('live 100'),
166+
'oldest surviving line is at the tail (trimmed from the bottom)');
146167

147168
// ─── 5. Emission-time: replay uses stored ts, never render-time ──────────────
148169
console.log('[5] replay shows EMISSION time, live shows current time');

0 commit comments

Comments
 (0)