/** * 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,'"'); } 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, '$1'); } // ── 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 `
` + `${e.ts}` + `${SEV_LABEL[e.level] ?? e.level}` + `${escapeHtml(e.node)}` + `${hl}` + `
`; } 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 = '' + nodes.map(n => ``).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();