Improve live log viewer — fix level filters, faster updates, add search

- Fix level filter showing nothing: now uses heuristic classification
  for print() output (error/traceback/failed→ERROR, warn→WARNING, etc.)
  in addition to exact logger format matching
- Speed up WebSocket updates from 2s to 0.5s polling
- Add search box with 300ms debounce — filters both initial load and live
- Use DocumentFragment for batch DOM appends (performance)
- Increase line cap from 1000 to 2000
- Backend search parameter support in /api/logs/tail
pull/324/head
Broque Thomas 4 weeks ago
parent 8b0e619fa1
commit 3404812a1e

@ -6282,21 +6282,38 @@ def get_log_tail():
}
log_path = log_map.get(log_source, log_map['app'])
search = request.args.get('search', '').lower()
def _classify_log_level(line):
"""Classify a log line's level. Returns DEBUG/INFO/WARNING/ERROR or empty for unclassified."""
if ' - DEBUG - ' in line: return 'DEBUG'
if ' - INFO - ' in line: return 'INFO'
if ' - WARNING - ' in line: return 'WARNING'
if ' - ERROR - ' in line or ' - CRITICAL - ' in line: return 'ERROR'
# Heuristic for print() output and non-logger lines
ll = line.lower()
if 'error' in ll or 'traceback' in ll or 'exception' in ll or 'failed' in ll: return 'ERROR'
if 'warning' in ll or 'warn' in ll: return 'WARNING'
if 'debug' in ll: return 'DEBUG'
return 'INFO' # Default unclassified lines to INFO
result_lines = []
if os.path.exists(log_path):
try:
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
all_lines = f.readlines()
# Read more lines than requested so filtering has enough to work with
tail = all_lines[-(lines * 3):] if level_filter else all_lines[-lines:]
pool_size = lines * 5 if (level_filter or search) else lines
tail = all_lines[-pool_size:]
for line in tail:
stripped = line.rstrip()
if not stripped:
continue
if level_filter and level_filter in ('DEBUG', 'INFO', 'WARNING', 'ERROR'):
# Match lines like "2026-04-18 12:00:00 - name - INFO - message"
if f' - {level_filter} - ' not in stripped:
if _classify_log_level(stripped) != level_filter:
continue
if search and search not in stripped.lower():
continue
result_lines.append(stripped)
# Trim to requested count after filtering
result_lines = result_lines[-lines:]
@ -54470,7 +54487,7 @@ def _emit_live_log_loop():
'source_reuse': os.path.join('logs', 'source_reuse.log'),
}
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(2)
socketio.sleep(0.5)
try:
# Read which source clients want (stored by subscribe handler)
source = getattr(_emit_live_log_loop, '_source', 'app')

@ -5867,6 +5867,7 @@
</div>
</div>
<div class="log-viewer-actions">
<input type="text" id="log-viewer-search" class="log-viewer-search" placeholder="Search logs..." oninput="_logViewerOnSearch(this)">
<button class="log-action-btn" onclick="_logViewerCopy()" title="Copy visible logs">Copy</button>
<button class="log-action-btn" onclick="_logViewerClear()" title="Clear display">Clear</button>
<label class="log-autoscroll-label">

@ -6984,7 +6984,26 @@ function _collectGenreWhitelist() {
let _logViewerActive = false;
let _logViewerFilter = '';
let _logViewerSource = 'app';
const _LOG_MAX_LINES = 1000;
let _logViewerSearch = '';
const _LOG_MAX_LINES = 2000;
function _logClassify(line) {
// Exact logger format first
if (line.includes(' - DEBUG - ')) return 'DEBUG';
if (line.includes(' - INFO - ')) return 'INFO';
if (line.includes(' - WARNING - ')) return 'WARNING';
if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) return 'ERROR';
// Heuristic for print() output
const ll = line.toLowerCase();
if (ll.includes('error') || ll.includes('traceback') || ll.includes('exception') || ll.includes('failed')) return 'ERROR';
if (ll.includes('warning') || ll.includes('warn')) return 'WARNING';
if (ll.includes('debug')) return 'DEBUG';
return 'INFO';
}
function _logClassToCSS(level) {
return { DEBUG: 'log-debug', INFO: 'log-info', WARNING: 'log-warning', ERROR: 'log-error' }[level] || 'log-plain';
}
async function _logViewerInit() {
if (_logViewerActive) return;
@ -6993,8 +7012,9 @@ async function _logViewerInit() {
// Fetch initial tail
try {
const params = new URLSearchParams({ source: _logViewerSource, lines: 200 });
const params = new URLSearchParams({ source: _logViewerSource, lines: 300 });
if (_logViewerFilter) params.set('level', _logViewerFilter);
if (_logViewerSearch) params.set('search', _logViewerSearch);
const resp = await fetch(`/api/logs/tail?${params}`);
const data = await resp.json();
if (data.lines) {
@ -7027,10 +7047,17 @@ function _logViewerStop() {
function _logViewerOnLive(data) {
if (!_logViewerActive || !data.lines) return;
if (data.source !== _logViewerSource) return;
const filtered = _logViewerFilter
? data.lines.filter(l => l.includes(` - ${_logViewerFilter} - `))
: data.lines;
if (filtered.length > 0) _logViewerAppendLines(filtered);
let lines = data.lines;
// Apply level filter client-side for live lines
if (_logViewerFilter) {
lines = lines.filter(l => _logClassify(l) === _logViewerFilter);
}
// Apply search filter
if (_logViewerSearch) {
const s = _logViewerSearch.toLowerCase();
lines = lines.filter(l => l.toLowerCase().includes(s));
}
if (lines.length > 0) _logViewerAppendLines(lines);
}
function _logViewerAppendLines(lines) {
@ -7039,12 +7066,14 @@ function _logViewerAppendLines(lines) {
const autoScroll = document.getElementById('log-viewer-autoscroll')?.checked;
const terminal = document.getElementById('log-viewer-terminal');
const frag = document.createDocumentFragment();
for (const line of lines) {
const div = document.createElement('div');
div.className = 'log-line ' + _logViewerGetClass(line);
div.className = 'log-line ' + _logClassToCSS(_logClassify(line));
div.textContent = line;
container.appendChild(div);
frag.appendChild(div);
}
container.appendChild(frag);
// Trim old lines
while (container.children.length > _LOG_MAX_LINES) {
@ -7061,14 +7090,6 @@ function _logViewerAppendLines(lines) {
}
}
function _logViewerGetClass(line) {
if (line.includes(' - DEBUG - ')) return 'log-debug';
if (line.includes(' - INFO - ')) return 'log-info';
if (line.includes(' - WARNING - ')) return 'log-warning';
if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) return 'log-error';
return 'log-plain';
}
async function _logViewerChangeSource() {
_logViewerStop();
_logViewerSource = document.getElementById('log-viewer-source')?.value || 'app';
@ -7081,10 +7102,22 @@ function _logViewerFilterLevel(btn) {
document.querySelectorAll('.log-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_logViewerFilter = btn.dataset.level || '';
// Reload with filter
_logViewerReload();
}
let _logSearchDebounce = null;
function _logViewerOnSearch(input) {
clearTimeout(_logSearchDebounce);
_logSearchDebounce = setTimeout(() => {
_logViewerSearch = (input.value || '').trim();
_logViewerReload();
}, 300);
}
function _logViewerReload() {
_logViewerStop();
const container = document.getElementById('log-viewer-lines');
if (container) container.innerHTML = '';
if (container) container.innerHTML = '<div class="log-line log-info">Loading...</div>';
_logViewerInit();
}

@ -17282,6 +17282,18 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.log-action-btn:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
.log-viewer-search {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #e0e0e0;
padding: 4px 10px;
font-size: 12px;
width: 160px;
outline: none;
}
.log-viewer-search:focus { border-color: rgba(255, 255, 255, 0.3); }
.log-autoscroll-label {
display: flex;
align-items: center;

Loading…
Cancel
Save