feat: WebUI event log panel (Issue #576) #579
209
ui/event_log_panel.css
Normal file
209
ui/event_log_panel.css
Normal file
@ -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; }
|
||||
}
|
||||
90
ui/event_log_panel.html
Normal file
90
ui/event_log_panel.html
Normal file
@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<title>Saltybot — Event Log</title>
|
||||
<link rel="stylesheet" href="event_log_panel.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Header ── -->
|
||||
<div id="header">
|
||||
<div class="logo">⚡ SALTYBOT — EVENT LOG</div>
|
||||
|
||||
<div id="conn-dot"></div>
|
||||
<input id="ws-input" type="text" value="ws://localhost:9090" placeholder="ws://robot-ip:9090" />
|
||||
<button id="btn-connect" class="hbtn">CONNECT</button>
|
||||
<span id="conn-label" style="color:#4b5563;font-size:10px">Not connected</span>
|
||||
|
||||
<span id="paused-indicator">⏸ PAUSED</span>
|
||||
<span id="count-badge">0 / 0</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Toolbar ── -->
|
||||
<div id="toolbar">
|
||||
|
||||
<!-- Severity filters -->
|
||||
<button class="sev-btn sev-debug" data-level="DEBUG">DEBUG</button>
|
||||
<button class="sev-btn sev-info" data-level="INFO" >INFO</button>
|
||||
<button class="sev-btn sev-warn" data-level="WARN" >WARN</button>
|
||||
<button class="sev-btn sev-error" data-level="ERROR">ERROR</button>
|
||||
<button class="sev-btn sev-fatal" data-level="FATAL">FATAL</button>
|
||||
<button class="sev-btn sev-event" data-level="EVENT">EVENT</button>
|
||||
|
||||
<div class="toolbar-sep"></div>
|
||||
|
||||
<!-- Node filter -->
|
||||
<select id="node-filter">
|
||||
<option value="">All nodes</option>
|
||||
</select>
|
||||
|
||||
<!-- Text search -->
|
||||
<input id="search-input" type="text" placeholder="Search… (Ctrl+F)" />
|
||||
|
||||
<div class="toolbar-sep"></div>
|
||||
|
||||
<!-- Actions -->
|
||||
<button id="btn-pause" class="hbtn active">⏸ PAUSE</button>
|
||||
<button id="btn-clear" class="hbtn">CLEAR</button>
|
||||
<button id="btn-export" class="hbtn">↓ CSV</button>
|
||||
|
||||
<!-- Ring buffer info -->
|
||||
<span style="font-size:9px;color:#374151;margin-left:auto">
|
||||
ring: 1000 entries · /rosout + /saltybot/events
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Main ── -->
|
||||
<div id="main">
|
||||
<div id="log-feed">
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="empty-state">
|
||||
<div class="icon">📋</div>
|
||||
<div>No events — connect to rosbridge</div>
|
||||
<div style="font-size:10px;color:#374151">
|
||||
Subscribing to /rosout and /saltybot/events
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Footer ── -->
|
||||
<div id="footer">
|
||||
<span>rosbridge: <code id="footer-ws">ws://localhost:9090</code></span>
|
||||
<span>topics: /rosout (rcl_interfaces/Log) · /saltybot/events (std_msgs/String)</span>
|
||||
<span>event log — issue #576</span>
|
||||
</div>
|
||||
|
||||
<script src="event_log_panel.js"></script>
|
||||
<script>
|
||||
// Sync footer WS URL
|
||||
document.getElementById('ws-input').addEventListener('input', (e) => {
|
||||
document.getElementById('footer-ws').textContent = e.target.value;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
386
ui/event_log_panel.js
Normal file
386
ui/event_log_panel.js
Normal file
@ -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,'>').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();
|
||||
Loading…
x
Reference in New Issue
Block a user