/** * 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 `