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
This commit is contained in:
parent
8b0e619fa1
commit
3404812a1e
4 changed files with 85 additions and 22 deletions
|
|
@ -6282,21 +6282,38 @@ def get_log_tail():
|
||||||
}
|
}
|
||||||
log_path = log_map.get(log_source, log_map['app'])
|
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 = []
|
result_lines = []
|
||||||
if os.path.exists(log_path):
|
if os.path.exists(log_path):
|
||||||
try:
|
try:
|
||||||
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
all_lines = f.readlines()
|
all_lines = f.readlines()
|
||||||
# Read more lines than requested so filtering has enough to work with
|
# 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:
|
for line in tail:
|
||||||
stripped = line.rstrip()
|
stripped = line.rstrip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
continue
|
continue
|
||||||
if level_filter and level_filter in ('DEBUG', 'INFO', 'WARNING', 'ERROR'):
|
if level_filter and level_filter in ('DEBUG', 'INFO', 'WARNING', 'ERROR'):
|
||||||
# Match lines like "2026-04-18 12:00:00 - name - INFO - message"
|
if _classify_log_level(stripped) != level_filter:
|
||||||
if f' - {level_filter} - ' not in stripped:
|
|
||||||
continue
|
continue
|
||||||
|
if search and search not in stripped.lower():
|
||||||
|
continue
|
||||||
result_lines.append(stripped)
|
result_lines.append(stripped)
|
||||||
# Trim to requested count after filtering
|
# Trim to requested count after filtering
|
||||||
result_lines = result_lines[-lines:]
|
result_lines = result_lines[-lines:]
|
||||||
|
|
@ -54470,7 +54487,7 @@ def _emit_live_log_loop():
|
||||||
'source_reuse': os.path.join('logs', 'source_reuse.log'),
|
'source_reuse': os.path.join('logs', 'source_reuse.log'),
|
||||||
}
|
}
|
||||||
while not globals().get('IS_SHUTTING_DOWN', False):
|
while not globals().get('IS_SHUTTING_DOWN', False):
|
||||||
socketio.sleep(2)
|
socketio.sleep(0.5)
|
||||||
try:
|
try:
|
||||||
# Read which source clients want (stored by subscribe handler)
|
# Read which source clients want (stored by subscribe handler)
|
||||||
source = getattr(_emit_live_log_loop, '_source', 'app')
|
source = getattr(_emit_live_log_loop, '_source', 'app')
|
||||||
|
|
|
||||||
|
|
@ -5867,6 +5867,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-viewer-actions">
|
<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="_logViewerCopy()" title="Copy visible logs">Copy</button>
|
||||||
<button class="log-action-btn" onclick="_logViewerClear()" title="Clear display">Clear</button>
|
<button class="log-action-btn" onclick="_logViewerClear()" title="Clear display">Clear</button>
|
||||||
<label class="log-autoscroll-label">
|
<label class="log-autoscroll-label">
|
||||||
|
|
|
||||||
|
|
@ -6984,7 +6984,26 @@ function _collectGenreWhitelist() {
|
||||||
let _logViewerActive = false;
|
let _logViewerActive = false;
|
||||||
let _logViewerFilter = '';
|
let _logViewerFilter = '';
|
||||||
let _logViewerSource = 'app';
|
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() {
|
async function _logViewerInit() {
|
||||||
if (_logViewerActive) return;
|
if (_logViewerActive) return;
|
||||||
|
|
@ -6993,8 +7012,9 @@ async function _logViewerInit() {
|
||||||
|
|
||||||
// Fetch initial tail
|
// Fetch initial tail
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ source: _logViewerSource, lines: 200 });
|
const params = new URLSearchParams({ source: _logViewerSource, lines: 300 });
|
||||||
if (_logViewerFilter) params.set('level', _logViewerFilter);
|
if (_logViewerFilter) params.set('level', _logViewerFilter);
|
||||||
|
if (_logViewerSearch) params.set('search', _logViewerSearch);
|
||||||
const resp = await fetch(`/api/logs/tail?${params}`);
|
const resp = await fetch(`/api/logs/tail?${params}`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.lines) {
|
if (data.lines) {
|
||||||
|
|
@ -7027,10 +7047,17 @@ function _logViewerStop() {
|
||||||
function _logViewerOnLive(data) {
|
function _logViewerOnLive(data) {
|
||||||
if (!_logViewerActive || !data.lines) return;
|
if (!_logViewerActive || !data.lines) return;
|
||||||
if (data.source !== _logViewerSource) return;
|
if (data.source !== _logViewerSource) return;
|
||||||
const filtered = _logViewerFilter
|
let lines = data.lines;
|
||||||
? data.lines.filter(l => l.includes(` - ${_logViewerFilter} - `))
|
// Apply level filter client-side for live lines
|
||||||
: data.lines;
|
if (_logViewerFilter) {
|
||||||
if (filtered.length > 0) _logViewerAppendLines(filtered);
|
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) {
|
function _logViewerAppendLines(lines) {
|
||||||
|
|
@ -7039,12 +7066,14 @@ function _logViewerAppendLines(lines) {
|
||||||
const autoScroll = document.getElementById('log-viewer-autoscroll')?.checked;
|
const autoScroll = document.getElementById('log-viewer-autoscroll')?.checked;
|
||||||
const terminal = document.getElementById('log-viewer-terminal');
|
const terminal = document.getElementById('log-viewer-terminal');
|
||||||
|
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'log-line ' + _logViewerGetClass(line);
|
div.className = 'log-line ' + _logClassToCSS(_logClassify(line));
|
||||||
div.textContent = line;
|
div.textContent = line;
|
||||||
container.appendChild(div);
|
frag.appendChild(div);
|
||||||
}
|
}
|
||||||
|
container.appendChild(frag);
|
||||||
|
|
||||||
// Trim old lines
|
// Trim old lines
|
||||||
while (container.children.length > _LOG_MAX_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() {
|
async function _logViewerChangeSource() {
|
||||||
_logViewerStop();
|
_logViewerStop();
|
||||||
_logViewerSource = document.getElementById('log-viewer-source')?.value || 'app';
|
_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'));
|
document.querySelectorAll('.log-filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
_logViewerFilter = btn.dataset.level || '';
|
_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();
|
_logViewerStop();
|
||||||
const container = document.getElementById('log-viewer-lines');
|
const container = document.getElementById('log-viewer-lines');
|
||||||
if (container) container.innerHTML = '';
|
if (container) container.innerHTML = '<div class="log-line log-info">Loading...</div>';
|
||||||
_logViewerInit();
|
_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-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 {
|
.log-autoscroll-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue