Standalone 3-file filterable real-time event log (no build step).
Files:
ui/event_log_panel.html — layout, toolbar, empty state
ui/event_log_panel.js — rosbridge subscriptions, ring buffer, render
ui/event_log_panel.css — dark-theme, responsive grid layout
Features:
- 1000-entry ring buffer (oldest dropped when full, FIFO)
- Subscribes /rosout (rcl_interfaces/msg/Log) + /saltybot/events (std_msgs/String JSON)
- Severity filter buttons: DEBUG / INFO / WARN / ERROR / FATAL / EVENT (toggle on/off)
- Node name filter: select dropdown populated from seen nodes
- Live text search with <mark> highlight, Ctrl+F shortcut, Esc to clear
- Auto-scroll to latest entry; pauses on mouse hover (messages still buffered)
- Manual pause/resume button; detects user scroll-up and stops auto-scroll
- CSV export of current filtered view with timestamp (filename includes ISO date)
- Clear all entries button
- Color-coded by severity: left border stripe + text color per level
- Entry columns: timestamp (ms precision) | severity | node | message
- [system] entries for connect/disconnect events
- WS URL persisted in localStorage
- Responsive: node column hidden on narrow screens
ROS topics:
SUB /rosout rcl_interfaces/msg/Log (all nodes)
SUB /saltybot/events std_msgs/String (JSON: {level,node,msg})
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
387 lines
13 KiB
JavaScript
387 lines
13 KiB
JavaScript
/**
|
|
* event_log_panel.js — Saltybot Event Log Panel (Issue #576)
|
|
*
|
|
* Subscribes via rosbridge WebSocket to:
|
|
* /rosout rcl_interfaces/msg/Log — ROS2 node messages
|
|
* /saltybot/events std_msgs/String (JSON) — custom robot events
|
|
*
|
|
* Features:
|
|
* - 1000-entry ring buffer (oldest dropped when full)
|
|
* - Filter by severity (DEBUG/INFO/WARN/ERROR/FATAL + EVENTS)
|
|
* - Filter by node name (select from seen nodes)
|
|
* - Text search with highlight
|
|
* - Auto-scroll (pauses when user scrolls up, resumes at bottom)
|
|
* - Pause on hover (stops auto-scroll, doesn't drop messages)
|
|
* - CSV export of current filtered view
|
|
* - Clear all / clear filtered
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
|
|
const RING_CAPACITY = 1000;
|
|
|
|
// ROS2 /rosout level byte values
|
|
const ROS_LEVELS = {
|
|
10: 'DEBUG',
|
|
20: 'INFO',
|
|
30: 'WARN',
|
|
40: 'ERROR',
|
|
50: 'FATAL',
|
|
};
|
|
|
|
const SEV_LABEL = {
|
|
DEBUG: 'DEBUG',
|
|
INFO: 'INFO ',
|
|
WARN: 'WARN ',
|
|
ERROR: 'ERROR',
|
|
FATAL: 'FATAL',
|
|
EVENT: 'EVENT',
|
|
};
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
let ros = null;
|
|
let rosoutSub = null;
|
|
let eventsSub = null;
|
|
|
|
// Ring buffer — array of entry objects
|
|
const ring = [];
|
|
let nextId = 0; // monotonic entry ID
|
|
|
|
// Seen node names for filter dropdown
|
|
const seenNodes = new Set();
|
|
|
|
// Filter state
|
|
const filters = {
|
|
levels: new Set(['DEBUG','INFO','WARN','ERROR','FATAL','EVENT']),
|
|
node: '', // '' = all
|
|
search: '',
|
|
};
|
|
|
|
// Auto-scroll + hover-pause
|
|
let autoScroll = true;
|
|
let hoverPaused = false;
|
|
let userScrolled = false; // user scrolled away manually
|
|
|
|
// Pending DOM rows to flush (batched for perf)
|
|
let pendingFlush = false;
|
|
|
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
|
|
|
const feed = document.getElementById('log-feed');
|
|
const emptyState = document.getElementById('empty-state');
|
|
const countBadge = document.getElementById('count-badge');
|
|
const pausedInd = document.getElementById('paused-indicator');
|
|
const searchEl = document.getElementById('search-input');
|
|
const nodeFilter = document.getElementById('node-filter');
|
|
|
|
// ── Utility ───────────────────────────────────────────────────────────────────
|
|
|
|
function tsNow() {
|
|
return new Date().toLocaleTimeString('en-US', {
|
|
hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
}) + '.' + String(Date.now() % 1000).padStart(3,'0');
|
|
}
|
|
|
|
function tsFromRos(stamp) {
|
|
if (!stamp) return tsNow();
|
|
const ms = stamp.sec * 1000 + Math.floor((stamp.nanosec ?? 0) / 1e6);
|
|
const d = new Date(ms);
|
|
return d.toLocaleTimeString('en-US', {
|
|
hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
}) + '.' + String(d.getMilliseconds()).padStart(3,'0');
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function highlightSearch(text, term) {
|
|
if (!term) return escapeHtml(text);
|
|
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
const re = new RegExp(`(${escaped})`, 'gi');
|
|
return escapeHtml(text).replace(re, '<mark>$1</mark>');
|
|
}
|
|
|
|
// ── Ring buffer ───────────────────────────────────────────────────────────────
|
|
|
|
function pushEntry(entry) {
|
|
entry.id = nextId++;
|
|
ring.push(entry);
|
|
if (ring.length > RING_CAPACITY) ring.shift();
|
|
|
|
if (!seenNodes.has(entry.node) && entry.node) {
|
|
seenNodes.add(entry.node);
|
|
rebuildNodeFilter();
|
|
}
|
|
}
|
|
|
|
// ── ROS connection ────────────────────────────────────────────────────────────
|
|
|
|
function connect() {
|
|
const url = document.getElementById('ws-input').value.trim();
|
|
if (!url) return;
|
|
if (ros) ros.close();
|
|
|
|
ros = new ROSLIB.Ros({ url });
|
|
|
|
ros.on('connection', () => {
|
|
document.getElementById('conn-dot').className = 'connected';
|
|
document.getElementById('conn-label').textContent = url;
|
|
document.getElementById('btn-connect').textContent = 'RECONNECT';
|
|
setupSubs();
|
|
addSystemEntry('Connected to ' + url, 'INFO');
|
|
});
|
|
|
|
ros.on('error', (err) => {
|
|
document.getElementById('conn-dot').className = 'error';
|
|
document.getElementById('conn-label').textContent = 'ERROR: ' + (err?.message || err);
|
|
teardown();
|
|
addSystemEntry('Connection error: ' + (err?.message || err), 'ERROR');
|
|
});
|
|
|
|
ros.on('close', () => {
|
|
document.getElementById('conn-dot').className = '';
|
|
document.getElementById('conn-label').textContent = 'Disconnected';
|
|
teardown();
|
|
addSystemEntry('Disconnected', 'WARN');
|
|
});
|
|
}
|
|
|
|
function setupSubs() {
|
|
// /rosout — ROS2 log messages
|
|
rosoutSub = new ROSLIB.Topic({
|
|
ros, name: '/rosout',
|
|
messageType: 'rcl_interfaces/msg/Log',
|
|
});
|
|
rosoutSub.subscribe((msg) => {
|
|
const level = ROS_LEVELS[msg.level] ?? 'INFO';
|
|
pushEntry({
|
|
ts: tsFromRos(msg.stamp),
|
|
level,
|
|
node: (msg.name || 'unknown').replace(/^\//, ''),
|
|
msg: msg.msg || '',
|
|
source: 'rosout',
|
|
});
|
|
scheduleRender();
|
|
});
|
|
|
|
// /saltybot/events — custom events (JSON string: {level, node, msg})
|
|
eventsSub = new ROSLIB.Topic({
|
|
ros, name: '/saltybot/events',
|
|
messageType: 'std_msgs/String',
|
|
});
|
|
eventsSub.subscribe((msg) => {
|
|
let level = 'EVENT', node = 'saltybot', text = msg.data;
|
|
try {
|
|
const parsed = JSON.parse(msg.data);
|
|
level = (parsed.level || 'EVENT').toUpperCase();
|
|
node = parsed.node || 'saltybot';
|
|
text = parsed.msg || parsed.message || msg.data;
|
|
} catch (_) { /* raw string */ }
|
|
|
|
pushEntry({ ts: tsNow(), level, node, msg: text, source: 'events' });
|
|
scheduleRender();
|
|
});
|
|
}
|
|
|
|
function teardown() {
|
|
if (rosoutSub) { rosoutSub.unsubscribe(); rosoutSub = null; }
|
|
if (eventsSub) { eventsSub.unsubscribe(); eventsSub = null; }
|
|
}
|
|
|
|
function addSystemEntry(text, level = 'INFO') {
|
|
pushEntry({ ts: tsNow(), level, node: '[system]', msg: text, source: 'system' });
|
|
scheduleRender();
|
|
}
|
|
|
|
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
|
|
function scheduleRender() {
|
|
if (pendingFlush) return;
|
|
pendingFlush = true;
|
|
requestAnimationFrame(renderAll);
|
|
}
|
|
|
|
function entryVisible(e) {
|
|
if (!filters.levels.has(e.level)) return false;
|
|
if (filters.node && e.node !== filters.node) return false;
|
|
if (filters.search) {
|
|
const q = filters.search.toLowerCase();
|
|
if (!e.msg.toLowerCase().includes(q) &&
|
|
!e.node.toLowerCase().includes(q)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function buildRow(e) {
|
|
const cls = 'sev-' + e.level.toLowerCase();
|
|
const hl = highlightSearch(e.msg, filters.search);
|
|
return `<div class="log-entry ${cls}" data-id="${e.id}">` +
|
|
`<span class="log-ts">${e.ts}</span>` +
|
|
`<span class="log-sev">${SEV_LABEL[e.level] ?? e.level}</span>` +
|
|
`<span class="log-node" title="${escapeHtml(e.node)}">${escapeHtml(e.node)}</span>` +
|
|
`<span class="log-msg">${hl}</span>` +
|
|
`</div>`;
|
|
}
|
|
|
|
function renderAll() {
|
|
pendingFlush = false;
|
|
|
|
const visible = ring.filter(entryVisible);
|
|
|
|
if (visible.length === 0) {
|
|
feed.innerHTML = '';
|
|
emptyState.style.display = 'flex';
|
|
countBadge.textContent = `0 / ${ring.length}`;
|
|
return;
|
|
}
|
|
|
|
emptyState.style.display = 'none';
|
|
feed.innerHTML = visible.map(buildRow).join('');
|
|
countBadge.textContent = `${visible.length} / ${ring.length}`;
|
|
|
|
maybeScrollBottom();
|
|
}
|
|
|
|
function maybeScrollBottom() {
|
|
if ((autoScroll && !hoverPaused && !userScrolled)) {
|
|
feed.scrollTop = feed.scrollHeight;
|
|
}
|
|
}
|
|
|
|
// ── Auto-scroll + pause logic ─────────────────────────────────────────────────
|
|
|
|
feed.addEventListener('mouseenter', () => {
|
|
hoverPaused = true;
|
|
pausedInd.classList.add('visible');
|
|
});
|
|
feed.addEventListener('mouseleave', () => {
|
|
hoverPaused = false;
|
|
pausedInd.classList.remove('visible');
|
|
if (autoScroll && !userScrolled) feed.scrollTop = feed.scrollHeight;
|
|
});
|
|
|
|
feed.addEventListener('scroll', () => {
|
|
const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;
|
|
if (atBottom) {
|
|
userScrolled = false;
|
|
} else if (!hoverPaused) {
|
|
userScrolled = true;
|
|
}
|
|
});
|
|
|
|
// Pause/resume button
|
|
document.getElementById('btn-pause').addEventListener('click', () => {
|
|
autoScroll = !autoScroll;
|
|
userScrolled = false;
|
|
const btn = document.getElementById('btn-pause');
|
|
if (autoScroll) {
|
|
btn.textContent = '⏸ PAUSE';
|
|
btn.classList.remove('pause');
|
|
btn.classList.add('active');
|
|
feed.scrollTop = feed.scrollHeight;
|
|
} else {
|
|
btn.textContent = '▶ RESUME';
|
|
btn.classList.add('pause');
|
|
btn.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
// ── Filter controls ───────────────────────────────────────────────────────────
|
|
|
|
document.querySelectorAll('.sev-btn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const lv = btn.dataset.level;
|
|
if (filters.levels.has(lv)) {
|
|
filters.levels.delete(lv);
|
|
btn.classList.add('off');
|
|
} else {
|
|
filters.levels.add(lv);
|
|
btn.classList.remove('off');
|
|
}
|
|
scheduleRender();
|
|
});
|
|
});
|
|
|
|
searchEl.addEventListener('input', () => {
|
|
filters.search = searchEl.value.trim();
|
|
scheduleRender();
|
|
});
|
|
|
|
nodeFilter.addEventListener('change', () => {
|
|
filters.node = nodeFilter.value;
|
|
scheduleRender();
|
|
});
|
|
|
|
function rebuildNodeFilter() {
|
|
const current = nodeFilter.value;
|
|
const nodes = [...seenNodes].sort();
|
|
nodeFilter.innerHTML = '<option value="">All nodes</option>' +
|
|
nodes.map(n => `<option value="${escapeHtml(n)}"${n===current?' selected':''}>${escapeHtml(n)}</option>`).join('');
|
|
}
|
|
|
|
// ── Clear ─────────────────────────────────────────────────────────────────────
|
|
|
|
document.getElementById('btn-clear').addEventListener('click', () => {
|
|
ring.length = 0;
|
|
seenNodes.clear();
|
|
rebuildNodeFilter();
|
|
scheduleRender();
|
|
});
|
|
|
|
// ── CSV export ────────────────────────────────────────────────────────────────
|
|
|
|
document.getElementById('btn-export').addEventListener('click', () => {
|
|
const visible = ring.filter(entryVisible);
|
|
if (visible.length === 0) return;
|
|
|
|
const header = 'timestamp,level,node,message\n';
|
|
const rows = visible.map((e) =>
|
|
[e.ts, e.level, e.node, '"' + e.msg.replace(/"/g,'""') + '"'].join(',')
|
|
);
|
|
const csv = header + rows.join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `saltybot_log_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
// ── Connect button ────────────────────────────────────────────────────────────
|
|
|
|
document.getElementById('btn-connect').addEventListener('click', connect);
|
|
document.getElementById('ws-input').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') connect();
|
|
});
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
const stored = localStorage.getItem('evlog_ws_url');
|
|
if (stored) document.getElementById('ws-input').value = stored;
|
|
document.getElementById('ws-input').addEventListener('change', (e) => {
|
|
localStorage.setItem('evlog_ws_url', e.target.value);
|
|
});
|
|
|
|
// Keyboard shortcut: Ctrl+F focuses search
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
e.preventDefault();
|
|
searchEl.focus();
|
|
searchEl.select();
|
|
}
|
|
// Escape: clear search
|
|
if (e.key === 'Escape' && document.activeElement === searchEl) {
|
|
searchEl.value = '';
|
|
filters.search = '';
|
|
scheduleRender();
|
|
}
|
|
});
|
|
|
|
// Initial empty state
|
|
scheduleRender();
|