From 44691742c872858f3a40bf8f5de1d0ad01b0e406 Mon Sep 17 00:00:00 2001 From: sl-webui Date: Sat, 14 Mar 2026 11:54:13 -0400 Subject: [PATCH] feat: WebUI event log panel (Issue #576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- ui/event_log_panel.css | 209 ++++++++++++++++++++++ ui/event_log_panel.html | 90 ++++++++++ ui/event_log_panel.js | 386 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 685 insertions(+) create mode 100644 ui/event_log_panel.css create mode 100644 ui/event_log_panel.html create mode 100644 ui/event_log_panel.js diff --git a/ui/event_log_panel.css b/ui/event_log_panel.css new file mode 100644 index 0000000..efb532f --- /dev/null +++ b/ui/event_log_panel.css @@ -0,0 +1,209 @@ +/* event_log_panel.css — Saltybot Event Log Panel (Issue #576) */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg0: #050510; + --bg1: #070712; + --bg2: #0a0a1a; + --bd: #0c2a3a; + --bd2: #1e3a5f; + --dim: #374151; + --mid: #6b7280; + --base: #9ca3af; + --hi: #d1d5db; + --cyan: #06b6d4; + --green: #22c55e; + --amber: #f59e0b; + --red: #ef4444; + --purple: #a855f7; +} + +body { + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + background: var(--bg0); + color: var(--base); + height: 100dvh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Header ── */ +#header { + display: flex; + align-items: center; + padding: 6px 12px; + background: var(--bg1); + border-bottom: 1px solid var(--bd); + flex-shrink: 0; + gap: 8px; + flex-wrap: wrap; +} +.logo { color: #f97316; font-weight: bold; letter-spacing: 0.15em; font-size: 13px; flex-shrink: 0; } + +#conn-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--dim); flex-shrink: 0; transition: background 0.3s; +} +#conn-dot.connected { background: var(--green); } +#conn-dot.error { background: var(--red); animation: blink 1s infinite; } + +@keyframes blink { 0%,100%{opacity:1}50%{opacity:.3} } + +#ws-input { + background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px; + color: #67e8f9; padding: 2px 7px; font-family: monospace; font-size: 11px; width: 190px; +} +#ws-input:focus { outline: none; border-color: var(--cyan); } + +.hbtn { + padding: 2px 8px; border-radius: 4px; border: 1px solid var(--bd2); + background: var(--bg2); color: #67e8f9; font-family: monospace; + font-size: 10px; font-weight: bold; cursor: pointer; transition: background .15s; white-space: nowrap; +} +.hbtn:hover { background: #0e4f69; } +.hbtn.active { background: #0e4f69; border-color: var(--cyan); } +.hbtn.pause { background: #451a03; border-color: #92400e; color: #fcd34d; } +.hbtn.pause:hover { background: #6b2b04; } + +#count-badge { + font-size: 10px; color: var(--mid); white-space: nowrap; margin-left: auto; +} +#paused-indicator { + font-size: 10px; color: #fcd34d; display: none; animation: blink 1.5s infinite; +} +#paused-indicator.visible { display: inline; } + +/* ── Toolbar ── */ +#toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + background: var(--bg1); + border-bottom: 1px solid var(--bd); + flex-shrink: 0; + flex-wrap: wrap; +} + +/* Severity filter buttons */ +.sev-btn { + padding: 2px 7px; border-radius: 3px; border: 1px solid; + font-family: monospace; font-size: 9px; font-weight: bold; + cursor: pointer; transition: opacity .15s, filter .15s; letter-spacing: 0.05em; +} +.sev-btn.off { opacity: 0.25; filter: grayscale(0.6); } + +.sev-debug { background: #1a1a2e; border-color: #4b5563; color: #9ca3af; } +.sev-info { background: #032a1e; border-color: #065f46; color: #34d399; } +.sev-warn { background: #3d1a00; border-color: #92400e; color: #fbbf24; } +.sev-error { background: #3d0000; border-color: #991b1b; color: #f87171; } +.sev-fatal { background: #2e003d; border-color: #7e22ce; color: #c084fc; } +.sev-event { background: #001a3d; border-color: #1d4ed8; color: #60a5fa; } + +/* Search input */ +#search-input { + background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px; + color: var(--hi); padding: 2px 7px; font-family: monospace; font-size: 11px; + width: 160px; +} +#search-input:focus { outline: none; border-color: var(--cyan); } + +#node-filter { + background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px; + color: var(--base); padding: 2px 5px; font-family: monospace; font-size: 10px; + max-width: 140px; +} +#node-filter:focus { outline: none; border-color: var(--cyan); } + +.toolbar-sep { width: 1px; height: 16px; background: var(--bd2); flex-shrink: 0; } + +/* ── Main layout ── */ +#main { + flex: 1; + display: flex; + min-height: 0; +} + +/* ── Log feed ── */ +#log-feed { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 4px 0; + scrollbar-width: thin; + scrollbar-color: var(--bd2) var(--bg0); +} +#log-feed::-webkit-scrollbar { width: 6px; } +#log-feed::-webkit-scrollbar-track { background: var(--bg0); } +#log-feed::-webkit-scrollbar-thumb { background: var(--bd2); border-radius: 3px; } + +/* ── Log entry ── */ +.log-entry { + display: grid; + grid-template-columns: 80px 54px minmax(80px,160px) 1fr; + gap: 0 8px; + align-items: baseline; + padding: 2px 12px; + border-bottom: 1px solid transparent; + transition: background 0.1s; + cursor: default; +} +.log-entry:hover { background: rgba(255,255,255,0.025); border-bottom-color: var(--bd); } + +.log-entry.sev-debug { border-left: 2px solid #4b5563; } +.log-entry.sev-info { border-left: 2px solid #065f46; } +.log-entry.sev-warn { border-left: 2px solid #92400e; } +.log-entry.sev-error { border-left: 2px solid #991b1b; } +.log-entry.sev-fatal { border-left: 2px solid #7e22ce; } +.log-entry.sev-event { border-left: 2px solid #1d4ed8; } + +.log-ts { color: var(--mid); font-size: 10px; white-space: nowrap; } +.log-sev { font-size: 9px; font-weight: bold; letter-spacing: 0.05em; white-space: nowrap; } +.log-node { color: var(--mid); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.log-msg { color: var(--hi); font-size: 11px; word-break: break-word; white-space: pre-wrap; } + +.log-entry.sev-debug .log-sev { color: #9ca3af; } +.log-entry.sev-info .log-sev { color: #34d399; } +.log-entry.sev-warn .log-sev { color: #fbbf24; } +.log-entry.sev-error .log-sev { color: #f87171; } +.log-entry.sev-fatal .log-sev { color: #c084fc; } +.log-entry.sev-event .log-sev { color: #60a5fa; } + +.log-entry.sev-error .log-msg { color: #fca5a5; } +.log-entry.sev-fatal .log-msg { color: #e9d5ff; } +.log-entry.sev-warn .log-msg { color: #fde68a; } + +/* Search highlight */ +mark { + background: rgba(234,179,8,0.35); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +/* Empty state */ +#empty-state { + display: flex; flex-direction: column; align-items: center; justify-content: center; + height: 100%; color: var(--dim); gap: 8px; + user-select: none; +} +#empty-state .icon { font-size: 40px; } + +/* ── Footer ── */ +#footer { + background: var(--bg1); border-top: 1px solid var(--bd); + padding: 3px 12px; + display: flex; align-items: center; justify-content: space-between; + flex-shrink: 0; font-size: 10px; color: var(--dim); +} + +/* ── Mobile ── */ +@media (max-width: 640px) { + .log-entry { grid-template-columns: 70px 44px 1fr; } + .log-node { display: none; } + #search-input { width: 100px; } + #node-filter { max-width: 90px; } +} diff --git a/ui/event_log_panel.html b/ui/event_log_panel.html new file mode 100644 index 0000000..0f500b8 --- /dev/null +++ b/ui/event_log_panel.html @@ -0,0 +1,90 @@ + + + + + +Saltybot — Event Log + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + + + + + + ring: 1000 entries · /rosout + /saltybot/events + +
+ + +
+
+ + +
+
📋
+
No events — connect to rosbridge
+
+ Subscribing to /rosout and /saltybot/events +
+
+ +
+
+ + + + + + + + diff --git a/ui/event_log_panel.js b/ui/event_log_panel.js new file mode 100644 index 0000000..362b87b --- /dev/null +++ b/ui/event_log_panel.js @@ -0,0 +1,386 @@ +/** + * 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(); -- 2.47.2