mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-04 16:03:07 +02:00
fd054a95bd
Three tabs open made every endpoint answer in ~20s, which two sessions read as contention on a shared server-side lock. It wasn't the server. With 3 tabs open, /health - which touches neither the database nor the device - measured a 14.7s median from inside a tab and 11ms from a client outside the browser at the same instant. The server was idle throughout. Socket.IO clients pinned transports: ['polling'], upgrade: false, so each tab held one HTTP connection open for its whole lifetime. Browsers allow six concurrent HTTP/1.1 connections per origin, shared across every tab, so three tabs consumed the pool and everything else queued in the browser waiting for a free connection. /proc/net/tcp in the container confirmed it: pinned at exactly 6 established connections, unmoving. The pin dates from1d47c9c, when werkzeug had no WebSocket support and every upgrade attempt returned HTTP 500. python-engineio==4.8.1 (pinned five weeks later, ind3590f9) pulls in simple-websocket, which fixed that; the workaround had outlived its premise. Drop it and use the default transports, which open on polling and upgrade. A WebSocket is not part of the HTTP pool, so the pool is released. Where the upgrade is blocked - a proxy that drops the Upgrade header - the client stays on polling by itself, which is exactly today's behaviour. Measured with 3 tabs, in-page medians: /health 14664ms -> 12ms, /api/status 19282ms -> 64ms, both now matching what the same probes read from outside the browser. All three tabs report transport "websocket", server pushes still arrive over it, and the log no longer fills with "Session is disconnected" (0 occurrences, 0 tracebacks across the run). Also corrects the earlier diagnosis in the docs: the per-endpoint timings that looked like a lock were measured request->requestfinished in the browser, which includes connection-queue time, so every endpoint flattened to the same figure regardless of its own cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
8.5 KiB
JavaScript
277 lines
8.5 KiB
JavaScript
/**
|
|
* System Log Viewer
|
|
*
|
|
* Real-time log streaming via WebSocket with filtering and search.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// --- DOM refs ---
|
|
const logEntries = document.getElementById('logEntries');
|
|
const loadingMsg = document.getElementById('loadingMsg');
|
|
const logCount = document.getElementById('logCount');
|
|
const statusDot = document.getElementById('statusDot');
|
|
const pauseBtn = document.getElementById('pauseBtn');
|
|
const pauseIcon = document.getElementById('pauseIcon');
|
|
const clearBtn = document.getElementById('clearBtn');
|
|
const levelFilter = document.getElementById('levelFilter');
|
|
const loggerFilter = document.getElementById('loggerFilter');
|
|
const searchFilter = document.getElementById('searchFilter');
|
|
const resetFilters = document.getElementById('resetFilters');
|
|
|
|
// --- State ---
|
|
let paused = false;
|
|
let autoScroll = true;
|
|
let entries = []; // all received entries
|
|
let displayCount = 0;
|
|
const MAX_DISPLAY = 3000; // max DOM entries before trimming
|
|
let searchDebounce = null;
|
|
let knownLoggers = new Set();
|
|
|
|
// --- Level ordering ---
|
|
const LEVEL_ORDER = { DEBUG: 0, INFO: 1, WARNING: 2, ERROR: 3, CRITICAL: 4 };
|
|
|
|
// --- WebSocket ---
|
|
// Default transports — see the note in app.js connectChatSocket().
|
|
const socket = io('/logs', {
|
|
reconnection: true,
|
|
reconnectionDelay: 2000,
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
setStatus('live');
|
|
// Load initial entries
|
|
loadInitialLogs();
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
setStatus('disconnected');
|
|
});
|
|
|
|
socket.on('log_entry', (entry) => {
|
|
addEntry(entry);
|
|
});
|
|
|
|
// --- Functions ---
|
|
|
|
function setStatus(state) {
|
|
statusDot.className = 'status-indicator ' + state;
|
|
}
|
|
|
|
function loadInitialLogs() {
|
|
const level = levelFilter.value;
|
|
const params = new URLSearchParams();
|
|
if (level) params.set('level', level);
|
|
params.set('limit', '1000');
|
|
|
|
fetch('/api/logs?' + params.toString())
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.success) return;
|
|
loadingMsg?.remove();
|
|
|
|
// Update logger filter options
|
|
if (data.loggers) {
|
|
data.loggers.forEach(l => knownLoggers.add(l));
|
|
updateLoggerOptions();
|
|
}
|
|
|
|
// Render entries
|
|
entries = data.entries || [];
|
|
renderAll();
|
|
})
|
|
.catch(err => {
|
|
if (loadingMsg) loadingMsg.textContent = 'Failed to load logs';
|
|
console.error('Failed to load logs:', err);
|
|
});
|
|
}
|
|
|
|
function addEntry(entry) {
|
|
entries.push(entry);
|
|
|
|
// Track new loggers
|
|
if (!knownLoggers.has(entry.logger)) {
|
|
knownLoggers.add(entry.logger);
|
|
updateLoggerOptions();
|
|
}
|
|
|
|
// If paused or filtered out, don't add to DOM
|
|
if (paused) {
|
|
updateCount();
|
|
return;
|
|
}
|
|
|
|
if (matchesFilter(entry)) {
|
|
appendEntryDOM(entry);
|
|
trimDOM();
|
|
if (autoScroll) scrollToBottom();
|
|
}
|
|
updateCount();
|
|
}
|
|
|
|
function matchesFilter(entry) {
|
|
// Level filter
|
|
const minLevel = levelFilter.value;
|
|
if (minLevel && (LEVEL_ORDER[entry.level] || 0) < (LEVEL_ORDER[minLevel] || 0)) {
|
|
return false;
|
|
}
|
|
|
|
// Logger filter
|
|
const loggerVal = loggerFilter.value;
|
|
if (loggerVal && !entry.logger.startsWith(loggerVal)) {
|
|
return false;
|
|
}
|
|
|
|
// Search filter
|
|
const search = searchFilter.value.trim().toLowerCase();
|
|
if (search && !entry.message.toLowerCase().includes(search) &&
|
|
!entry.logger.toLowerCase().includes(search)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function renderAll() {
|
|
logEntries.innerHTML = '';
|
|
displayCount = 0;
|
|
|
|
const filtered = entries.filter(e => matchesFilter(e));
|
|
// Only render last MAX_DISPLAY entries
|
|
const toRender = filtered.slice(-MAX_DISPLAY);
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
for (const entry of toRender) {
|
|
fragment.appendChild(createEntryElement(entry));
|
|
displayCount++;
|
|
}
|
|
|
|
logEntries.appendChild(fragment);
|
|
updateCount();
|
|
scrollToBottom();
|
|
}
|
|
|
|
function appendEntryDOM(entry) {
|
|
logEntries.appendChild(createEntryElement(entry));
|
|
displayCount++;
|
|
}
|
|
|
|
function createEntryElement(entry) {
|
|
const div = document.createElement('div');
|
|
div.className = 'log-line';
|
|
|
|
// Shorten timestamp to HH:MM:SS.mmm
|
|
const ts = entry.timestamp.length > 11 ? entry.timestamp.substring(11) : entry.timestamp;
|
|
|
|
// Shorten logger name (remove 'app.' prefix)
|
|
let loggerName = entry.logger;
|
|
if (loggerName.startsWith('app.')) {
|
|
loggerName = loggerName.substring(4);
|
|
}
|
|
|
|
// Pad level to 5 chars
|
|
const levelPad = entry.level.padEnd(5);
|
|
|
|
// Build the line with color spans
|
|
const search = searchFilter.value.trim().toLowerCase();
|
|
let message = escapeHtml(entry.message);
|
|
if (search) {
|
|
message = highlightSearch(message, search);
|
|
}
|
|
|
|
div.innerHTML =
|
|
`<span class="log-ts">${escapeHtml(ts)}</span> ` +
|
|
`<span class="log-level-${entry.level}">${escapeHtml(levelPad)}</span> ` +
|
|
`<span class="log-logger">${escapeHtml(loggerName.padEnd(18).substring(0, 18))}</span> ` +
|
|
`<span class="log-msg-${entry.level}">${message}</span>`;
|
|
|
|
return div;
|
|
}
|
|
|
|
function trimDOM() {
|
|
while (logEntries.children.length > MAX_DISPLAY) {
|
|
logEntries.removeChild(logEntries.firstChild);
|
|
displayCount--;
|
|
}
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
logEntries.scrollTop = logEntries.scrollHeight;
|
|
}
|
|
|
|
function updateCount() {
|
|
const total = entries.length;
|
|
const shown = logEntries.children.length;
|
|
logCount.textContent = shown === total
|
|
? `${total} entries`
|
|
: `${shown} / ${total} entries`;
|
|
}
|
|
|
|
function updateLoggerOptions() {
|
|
const current = loggerFilter.value;
|
|
// Group loggers by top-level module
|
|
const sorted = Array.from(knownLoggers).sort();
|
|
|
|
loggerFilter.innerHTML = '<option value="">All modules</option>';
|
|
for (const name of sorted) {
|
|
const opt = document.createElement('option');
|
|
opt.value = name;
|
|
// Shorten display
|
|
opt.textContent = name.startsWith('app.') ? name.substring(4) : name;
|
|
if (name === current) opt.selected = true;
|
|
loggerFilter.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function highlightSearch(html, search) {
|
|
if (!search) return html;
|
|
// Case-insensitive highlight (on already-escaped HTML)
|
|
const regex = new RegExp('(' + search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
|
|
return html.replace(regex, '<mark>$1</mark>');
|
|
}
|
|
|
|
// --- Auto-scroll detection ---
|
|
logEntries.addEventListener('scroll', () => {
|
|
const atBottom = logEntries.scrollHeight - logEntries.scrollTop - logEntries.clientHeight < 50;
|
|
autoScroll = atBottom;
|
|
});
|
|
|
|
// --- Controls ---
|
|
pauseBtn.addEventListener('click', () => {
|
|
paused = !paused;
|
|
pauseIcon.className = paused ? 'bi bi-play-fill' : 'bi bi-pause-fill';
|
|
setStatus(paused ? 'paused' : 'live');
|
|
if (!paused) {
|
|
// Resume: re-render to catch up
|
|
renderAll();
|
|
}
|
|
});
|
|
|
|
clearBtn.addEventListener('click', () => {
|
|
entries = [];
|
|
logEntries.innerHTML = '';
|
|
displayCount = 0;
|
|
updateCount();
|
|
});
|
|
|
|
// Filter handlers
|
|
levelFilter.addEventListener('change', () => renderAll());
|
|
loggerFilter.addEventListener('change', () => renderAll());
|
|
searchFilter.addEventListener('input', () => {
|
|
clearTimeout(searchDebounce);
|
|
searchDebounce = setTimeout(() => renderAll(), 250);
|
|
});
|
|
resetFilters.addEventListener('click', () => {
|
|
levelFilter.value = 'INFO';
|
|
loggerFilter.value = '';
|
|
searchFilter.value = '';
|
|
renderAll();
|
|
});
|
|
})();
|