Skip to content

Commit 4a642c3

Browse files
feat(logs): source-categorized logging (EVENTS/SYS/NODE) + events.log + filters
Every log line now gets one category at the source: - classifyLogCategory(msg) in server.js (EVENTS→SYS→NODE, first match wins), mirrored byte-identically as logCategory(msg) in admin.html. - broadcast() sets data.cat before emit (logBuffer stays string[]; replayed init lines are re-classified client-side with the same rules). - EVENTS lines persist to results/events.log (separate from the per-run test log), 1-file rotation at ~2MB. The pipeline's per-run audit.log now tees only non-events lines, so EVENTS (lifecycle) and the run's SYS+NODE stream are kept separate on disk, as requested. - admin Live Log gets ALL/EVENTS/SYS/NODE filter buttons (mirrors /live), each row tagged data-cat (server cat for live lines, re-derived for replay). Adversarially reviewed (no CRITICAL/HIGH; mechanism — data.cat mutation reaches the pipeline wrapper, rotation safety, server↔client classifier identity — all verified). Applied the two MEDIUM classifier fixes the review found: dropped bare '💾' from EVENTS (it caught the per-node "💾 Cached:" line, mis-routing it to events.log and dropping it from the run log; 'Saved' already covers the lifecycle lines), and added 'Resuming Test' to EVENTS. Test suite green (188/31/45/35).
1 parent 09774a4 commit 4a642c3

3 files changed

Lines changed: 113 additions & 4 deletions

File tree

admin.html

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,20 @@
572572
text-transform: uppercase;
573573
}
574574

575+
/* Live Log category filters — mirrors public /live's .log-filters mechanism */
576+
.log-filters {
577+
display: inline-flex; gap: 2px;
578+
border: 1px solid var(--border); border-radius: 5px;
579+
overflow: hidden; font-size: 10px; letter-spacing: 0.5px;
580+
font-family: var(--font-display);
581+
}
582+
.log-filter-btn {
583+
padding: 3px 8px; color: var(--text-muted); font-weight: 600;
584+
cursor: pointer; user-select: none;
585+
}
586+
.log-filter-btn:hover { color: var(--text); }
587+
.log-filter-btn.active { background: var(--bg-input); color: var(--text); }
588+
575589
.table-wrap { overflow-y: auto; scrollbar-gutter: stable; flex: 1; min-height: 0; padding: 0 8px 8px 8px; }
576590
/* Fixed header table (outside the scroll area). scrollbar-gutter:stable on
577591
both wraps reserves an equal right-side gutter so the header columns line
@@ -1073,6 +1087,12 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
10731087
<div class="glass-panel log-container">
10741088
<div class="panel-header">
10751089
<div class="panel-title">Live Log</div>
1090+
<span class="log-filters" id="logFilters">
1091+
<span class="log-filter-btn active" data-lf="all" onclick="setLogFilter('all')">ALL</span>
1092+
<span class="log-filter-btn" data-lf="events" onclick="setLogFilter('events')">EVENTS</span>
1093+
<span class="log-filter-btn" data-lf="sys" onclick="setLogFilter('sys')">SYS</span>
1094+
<span class="log-filter-btn" data-lf="node" onclick="setLogFilter('node')">NODE</span>
1095+
</span>
10761096
</div>
10771097
<div class="logs" id="logBody"></div>
10781098
</div>
@@ -1440,7 +1460,8 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
14401460
if (msg.result) { upsertLocal(msg.result); addSingleRow(msg.result); }
14411461
applyState();
14421462
} else if (msg.type === 'log') {
1443-
appendLog(msg.msg);
1463+
// Live lines carry a server-sent category; prefer it over re-deriving.
1464+
appendLog(msg.msg, msg.cat);
14441465
}
14451466
};
14461467
eventSource.onerror = () => {
@@ -2036,18 +2057,56 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
20362057
prepend ? tbody.insertBefore(tr, tbody.firstChild) : tbody.appendChild(tr);
20372058
}
20382059

2039-
function appendLog(msg) {
2060+
// ─── Live Log category filter ────────────────────────────────────────────
2061+
// Every log line gets exactly ONE category: 'events' | 'sys' | 'node'.
2062+
// First match wins, EVENTS → SYS → NODE. These keyword lists MUST stay
2063+
// byte-identical to server.js's classifyLogCategory() so categorization is
2064+
// consistent between live SSE lines and replayed init buffer lines.
2065+
function logCategory(msg) {
2066+
const s = String(msg == null ? '' : msg);
2067+
const EVENTS = ['Starting Test', 'Resuming Test', 'Stop requested', '⏹', 'Loop continues', '♾', 'Deleted Test', '🗑', 'Saved', 'Loaded Test', '📂', 'DNS', '🔧', 'On-chain reporting', 'On-chain report posted', 'Setting up wallet', '🔑', 'Log file', '📝', 'subscribing', '📋', 'SDK switched', 'Broadcast'];
2068+
const SYS = ['baseline', 'Baseline', 'Balance', '💰', 'internet', 'Internet', 'connectivity', '🌐', 'Transport cache', '🧠', 'Fetching node list', '🔍', 'V2Ray:', 'WireGuard:', 'Admin:', 'Cloudflare', 'Discovered', 'active plans', 'online scan', 'Scanning'];
2069+
for (const k of EVENTS) if (s.includes(k)) return 'events';
2070+
for (const k of SYS) if (s.includes(k)) return 'sys';
2071+
return 'node';
2072+
}
2073+
2074+
let _logFilter = 'all'; // 'all' | 'events' | 'sys' | 'node'
2075+
2076+
function setLogFilter(f) {
2077+
_logFilter = f;
2078+
document.querySelectorAll('#logFilters .log-filter-btn').forEach(b => {
2079+
b.classList.toggle('active', b.dataset.lf === f);
2080+
});
2081+
const body = document.getElementById('logBody');
2082+
if (!body) return;
2083+
for (const row of body.children) {
2084+
row.style.display = _logEntryMatches(row, f) ? '' : 'none';
2085+
}
2086+
body.scrollTop = body.scrollHeight;
2087+
}
2088+
2089+
function _logEntryMatches(row, f) {
2090+
if (f === 'all') return true;
2091+
return (row.dataset.cat || 'node') === f;
2092+
}
2093+
2094+
// `cat` is the server-sent category for live SSE lines; replayed init buffer
2095+
// lines arrive as plain strings (no cat), so re-derive via logCategory().
2096+
function appendLog(msg, cat) {
20402097
const body = document.getElementById('logBody');
20412098
const div = document.createElement('div');
20422099
let cls = '';
20432100
if (/|warn|pause|insufficient/i.test(msg)) cls = 'log-warn';
20442101
else if (/|complete||success/i.test(msg)) cls = 'log-ok';
20452102
else if (/error|fail|ERR/i.test(msg)) cls = 'log-err';
20462103
div.className = 'log-entry ' + cls;
2104+
div.dataset.cat = cat || logCategory(msg);
20472105
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
20482106
const escaped = String(msg).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
20492107
const linkified = escaped.replace(/(https?:\/\/[^\s<]+)/g, (u) => `<a href="${u}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:underline">${u}</a>`);
20502108
div.innerHTML = `<span class="log-time">${ts}</span>` + linkified;
2109+
if (!_logEntryMatches(div, _logFilter)) div.style.display = 'none';
20512110
body.appendChild(div);
20522111
body.scrollTop = body.scrollHeight;
20532112
if (body.children.length > 500) body.removeChild(body.firstChild);

audit/pipeline.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,10 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null,
592592
const origBroadcast = broadcast;
593593
broadcast = (type, data) => {
594594
origBroadcast(type, data);
595-
if (type === 'log' && data?.msg) logLine(data.msg);
595+
// The per-run audit log is the TEST log (SYS + NODE). Lifecycle EVENTS live
596+
// in results/events.log instead. origBroadcast runs first and sets data.cat,
597+
// so this tee only writes non-events lines into runs/test-NNN/audit.log.
598+
if (type === 'log' && data?.msg && data.cat !== 'events') logLine(data.msg);
596599
};
597600

598601
broadcast('log', { msg: `📝 Log file: results/${path.basename(auditLogPath)}${resume ? ' (resumed — appending)' : ''}` });

server.js

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,56 @@ function saveStateSnapshot(force = false) {
285285
// lands inside the 5s throttle window.
286286
function flushStateSnapshot() { saveStateSnapshot(true); }
287287

288+
// ─── Log categorization ──────────────────────────────────────────────────────
289+
// Every log line gets exactly ONE category: 'events' | 'sys' | 'node'.
290+
// EVENTS — operator/lifecycle (start/stop/save/load, on-chain, wallet, DNS…)
291+
// SYS — in-run diagnostics (baseline, balance, connectivity, scan…)
292+
// NODE — per-node results incl. failures (default).
293+
// First match wins, checked in EVENTS → SYS → NODE order. The 📡 emoji is shared
294+
// by on-chain (events) and baseline (sys), so classify by the WORDS, not emoji.
295+
// IMPORTANT: keep these keyword lists byte-identical to admin.html's logCategory().
296+
function classifyLogCategory(msg) {
297+
const s = String(msg == null ? '' : msg);
298+
// NOTE: no bare '💾' — it also prefixes the per-node "💾 Cached:" transport
299+
// line; 'Saved' already covers the lifecycle save lines. 'Resuming Test' is a
300+
// lifecycle sibling of 'Starting Test'.
301+
const EVENTS = ['Starting Test', 'Resuming Test', 'Stop requested', '⏹', 'Loop continues', '♾', 'Deleted Test', '🗑', 'Saved', 'Loaded Test', '📂', 'DNS', '🔧', 'On-chain reporting', 'On-chain report posted', 'Setting up wallet', '🔑', 'Log file', '📝', 'subscribing', '📋', 'SDK switched', 'Broadcast'];
302+
const SYS = ['baseline', 'Baseline', 'Balance', '💰', 'internet', 'Internet', 'connectivity', '🌐', 'Transport cache', '🧠', 'Fetching node list', '🔍', 'V2Ray:', 'WireGuard:', 'Admin:', 'Cloudflare', 'Discovered', 'active plans', 'online scan', 'Scanning'];
303+
for (const k of EVENTS) if (s.includes(k)) return 'events';
304+
for (const k of SYS) if (s.includes(k)) return 'sys';
305+
return 'node';
306+
}
307+
308+
// EVENTS persist to a file (separate from per-run runs/test-NNN/audit.log), with
309+
// a simple 1-file rotation at ~2MB so it can't grow unbounded.
310+
const EVENTS_LOG_FILE = path.join(__dirname, 'results', 'events.log');
311+
const EVENTS_LOG_MAX_BYTES = 2 * 1024 * 1024;
312+
function appendEventLog(msg) {
313+
try {
314+
try {
315+
const st = _statSync(EVENTS_LOG_FILE);
316+
if (st && st.size > EVENTS_LOG_MAX_BYTES) {
317+
_rfs2(EVENTS_LOG_FILE, EVENTS_LOG_FILE + '.1');
318+
}
319+
} catch (e) {
320+
// ENOENT (no file yet) is expected — only log genuine stat/rotate errors.
321+
if (e && e.code !== 'ENOENT') console.error('[events.log] rotate failed:', e.message);
322+
}
323+
_afs(EVENTS_LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`, 'utf8');
324+
} catch (e) {
325+
console.error('[events.log] append failed:', e.message);
326+
}
327+
}
328+
288329
function broadcast(type, data = {}) {
289330
if (type === 'log' && data.msg) {
331+
// Tag the live SSE 'log' event with a category so admin/live can filter
332+
// without re-deriving it. logBuffer stays an array of strings (the client
333+
// re-classifies replayed init lines). Set BEFORE emitter.emit below.
334+
data.cat = data.cat || classifyLogCategory(data.msg);
290335
logBuffer.push(data.msg);
291336
if (logBuffer.length > LOG_BUFFER_MAX) logBuffer.shift();
337+
if (data.cat === 'events') appendEventLog(data.msg);
292338
}
293339
if (type === 'state' || type === 'result') saveStateSnapshot();
294340
// NOTE: spread `data` FIRST so a payload field named `type` (e.g. the node's
@@ -509,7 +555,7 @@ function hydrateLogBufferFromFile(filePath) {
509555
}
510556

511557
// ─── Test Run Management ─────────────────────────────────────────────────────
512-
import { readFileSync as _rfs, writeFileSync as _wfs, mkdirSync as _mkd, existsSync as _ex, readdirSync as _rd, copyFileSync as _cp, rmSync as _rm } from 'fs';
558+
import { readFileSync as _rfs, writeFileSync as _wfs, mkdirSync as _mkd, existsSync as _ex, readdirSync as _rd, copyFileSync as _cp, rmSync as _rm, statSync as _statSync, renameSync as _rfs2, appendFileSync as _afs } from 'fs';
513559

514560
const RUNS_DIR = path.join(__dirname, 'results', 'runs');
515561
const RUNS_INDEX = path.join(RUNS_DIR, 'index.json');
@@ -1812,6 +1858,7 @@ function sanitizeForPublic(evt) {
18121858
if (evt.errorCode != null) safe.errorCode = evt.errorCode;
18131859
if (evt.testedAt != null) safe.testedAt = evt.testedAt;
18141860
if (evt.msg != null) safe.msg = String(evt.msg).slice(0, 400);
1861+
if (evt.cat != null) safe.cat = evt.cat;
18151862
if (evt.baselineMbps != null) safe.baselineMbps = evt.baselineMbps;
18161863
if (evt.skipped === true) safe.skipped = true;
18171864
if (evt.inPlan === true) safe.inPlan = true;

0 commit comments

Comments
 (0)