mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(errors): serve /api/v3/errors/* from the display service's aggregator The error aggregator is a per-process singleton and only the display service runs plugins, so only its aggregator records anything. The routes read the web process's own, empty one and always reported no errors. The display service now publishes a bounded snapshot of its aggregator to the shared cache (plugin_error_snapshot) from a daemon thread: at most once every 10 s and only when something changed, never raising into the caller. The routes read it and keep their response shapes, adding snapshot_available, generated_at and clear_pending; exception text has credentials redacted. POST /errors/clear writes a clear request (plugin_error_clear_request) that the display applies on its next 5 s tick via the new clear_before(), which keeps errors recorded after the cutoff and rebuilds the counts. Until the snapshot acknowledges the request, reads hide everything before the cutoff, so a snapshot written just before the click cannot bring errors back. Adds "all": true; cleared_count is null when only the display can know it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): show plugin errors in the Logs tab A compact panel under the log viewer: per-plugin error counts, repeating errors (type, count, affected plugins, a sample message, last seen) and a Clear button, with empty states for "no errors" and "display service hasn't reported yet". Polls every 15 s while the tab is active; all text goes through escapeHtml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: describe where plugin error reports come from and how clear works Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(errors): redact the published snapshot before clipping it Keeping only a traceback's tail (or clipping a message) could cut an `api_key=` marker off while keeping the secret after it, and the web side's redaction would then have nothing to match. The display now redacts every free-text field of the snapshot first. The patterns move to a Flask-free src/redaction.py so the display service can use them; redact_text in the web error handler uses the same function, unchanged in behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
950 lines
37 KiB
HTML
950 lines
37 KiB
HTML
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||
<h2 class="text-lg font-semibold text-gray-900">System Logs</h2>
|
||
<p class="mt-1 text-sm text-gray-600">View real-time logs from the LED matrix service for troubleshooting.</p>
|
||
</div>
|
||
|
||
<!-- Currently displayed plugin -->
|
||
<div id="current-plugin-banner" class="hidden mb-4 flex items-center gap-2 text-sm bg-blue-50 border border-blue-200 text-blue-800 rounded-lg px-3 py-2">
|
||
<i class="fas fa-tv"></i>
|
||
<span>Now showing: <strong id="current-plugin-mode">-</strong>
|
||
<span id="current-plugin-id-wrap" class="hidden">(plugin: <code id="current-plugin-id" class="bg-blue-100 px-1 rounded"></code>)</span>
|
||
</span>
|
||
<button id="current-plugin-filter-btn" class="hidden ml-auto btn bg-blue-600 hover:bg-blue-700 text-white px-2 py-0.5 rounded text-xs">
|
||
Filter to this plugin
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Controls -->
|
||
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||
<div class="flex flex-wrap items-center gap-3 sm:gap-4">
|
||
<!-- Log Mode Toggle -->
|
||
<div class="flex items-center gap-2">
|
||
<label class="flex items-center">
|
||
<input type="checkbox" id="log-realtime-toggle" class="form-control h-4 w-4" checked>
|
||
<span class="ml-2 text-sm font-medium">Real-time</span>
|
||
</label>
|
||
<span class="text-sm text-gray-600">|</span>
|
||
<button id="refresh-logs-btn" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm">
|
||
<i class="fas fa-sync-alt mr-1"></i>Refresh
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Log Level Filter -->
|
||
<select id="log-level-filter" aria-label="Filter logs by level" class="form-control text-sm w-auto">
|
||
<option value="">All Levels</option>
|
||
<option value="ERROR">Errors Only</option>
|
||
<option value="WARNING">Warnings & Errors</option>
|
||
<option value="INFO">Info & Above</option>
|
||
</select>
|
||
|
||
<!-- Plugin Filter -->
|
||
<select id="log-plugin-filter" aria-label="Filter logs by plugin" class="form-control text-sm w-auto">
|
||
<option value="">All Plugins</option>
|
||
</select>
|
||
|
||
<!-- Search -->
|
||
<div class="relative">
|
||
<input type="text" id="log-search" placeholder="Search logs..." aria-label="Search logs" class="form-control text-sm pl-8 pr-4 py-1 w-48">
|
||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs pointer-events-none" aria-hidden="true"></i>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<!-- Auto-scroll toggle -->
|
||
<label class="flex items-center">
|
||
<input type="checkbox" id="log-autoscroll" class="form-control h-4 w-4" checked>
|
||
<span class="ml-2 text-sm">Auto-scroll</span>
|
||
</label>
|
||
|
||
<!-- Clear logs -->
|
||
<button id="clear-logs-btn" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm">
|
||
<i class="fas fa-trash mr-1"></i>Clear
|
||
</button>
|
||
|
||
<!-- Download logs -->
|
||
<button id="download-logs-btn" class="btn bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm">
|
||
<i class="fas fa-download mr-1"></i>Download
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Log Display -->
|
||
<div class="relative">
|
||
<div id="logs-container" class="bg-gray-900 text-gray-100 font-mono text-sm rounded-lg p-3 border border-gray-700 shadow-inner relative" style="height: 500px; min-height: 400px; max-height: 70vh;">
|
||
<div id="logs-loading" class="absolute inset-0 flex items-center justify-center text-gray-300 bg-gray-900">
|
||
<div class="text-center">
|
||
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
|
||
<p>Loading logs...</p>
|
||
</div>
|
||
</div>
|
||
<div id="logs-display" class="hidden absolute inset-0 overflow-y-auto bg-gray-900">
|
||
<div class="logs-content p-0">
|
||
<!-- Logs will be inserted here -->
|
||
</div>
|
||
</div>
|
||
<div id="logs-empty" class="hidden absolute inset-0 flex items-center justify-center text-gray-300 bg-gray-900">
|
||
<div class="text-center">
|
||
<i class="fas fa-file-alt text-4xl mb-2"></i>
|
||
<p>No logs available</p>
|
||
<p class="text-sm mt-2">Logs will appear here when the service runs</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Log stats -->
|
||
<div id="log-stats" class="absolute top-2 right-2 bg-black bg-opacity-70 text-white text-xs px-3 py-1.5 rounded-md backdrop-blur-sm hidden">
|
||
<i class="fas fa-list-ul mr-1"></i>
|
||
<span id="log-count">0</span> entries
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Connection Status -->
|
||
<div id="log-connection-status" class="mt-4 text-sm text-gray-600 flex items-center space-x-2">
|
||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||
<span>Connected to log stream</span>
|
||
</div>
|
||
|
||
<!-- Plugin errors, as recorded by the display service -->
|
||
<div id="plugin-errors-panel" class="mt-6 border-t border-gray-200 pt-4">
|
||
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||
<div>
|
||
<h3 class="text-base font-semibold text-gray-900">Plugin errors</h3>
|
||
<p id="plugin-errors-meta" class="text-xs text-gray-600">Recorded by the display service</p>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<button id="plugin-errors-refresh-btn" type="button" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm">
|
||
<i class="fas fa-sync-alt mr-1"></i>Refresh
|
||
</button>
|
||
<button id="plugin-errors-clear-btn" type="button" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm">
|
||
<i class="fas fa-eraser mr-1"></i>Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="plugin-errors-body" class="text-sm text-gray-600" aria-live="polite">Loading...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Global variables - use window properties to avoid redeclaration errors with HTMX reloads
|
||
// Initialize only if not already defined
|
||
if (typeof window._logsEventSource === 'undefined') {
|
||
window._logsEventSource = null;
|
||
}
|
||
if (typeof window._allLogs === 'undefined') {
|
||
window._allLogs = [];
|
||
}
|
||
if (typeof window._filteredLogs === 'undefined') {
|
||
window._filteredLogs = [];
|
||
}
|
||
if (typeof window._logContainer === 'undefined') {
|
||
window._logContainer = null;
|
||
}
|
||
if (typeof window._logsContent === 'undefined') {
|
||
window._logsContent = null;
|
||
}
|
||
if (typeof window._isRealtime === 'undefined') {
|
||
window._isRealtime = true;
|
||
}
|
||
if (typeof window._MAX_LOGS === 'undefined') {
|
||
window._MAX_LOGS = 500; // Maximum number of logs to keep in memory
|
||
}
|
||
|
||
// Use window properties directly to avoid redeclaration issues
|
||
// Clean up any existing event source before reinitializing
|
||
if (window._logsEventSource) {
|
||
window._logsEventSource.close();
|
||
window._logsEventSource = null;
|
||
}
|
||
|
||
// Reset arrays on reload
|
||
window._allLogs = [];
|
||
window._filteredLogs = [];
|
||
|
||
// Initialize immediately (this script runs when the partial is loaded)
|
||
(function() {
|
||
window._logContainer = document.getElementById('logs-container');
|
||
window._logsContent = document.querySelector('#logs-display .logs-content');
|
||
|
||
// Logs container initialized successfully
|
||
|
||
initializeLogs();
|
||
|
||
// Event listeners - remove old ones first to prevent duplicates
|
||
const realtimeToggle = document.getElementById('log-realtime-toggle');
|
||
const refreshBtn = document.getElementById('refresh-logs-btn');
|
||
const levelFilter = document.getElementById('log-level-filter');
|
||
const pluginFilter = document.getElementById('log-plugin-filter');
|
||
const searchInput = document.getElementById('log-search');
|
||
const autoscrollToggle = document.getElementById('log-autoscroll');
|
||
const clearBtn = document.getElementById('clear-logs-btn');
|
||
const downloadBtn = document.getElementById('download-logs-btn');
|
||
|
||
// Clone and replace to remove old listeners
|
||
if (realtimeToggle) {
|
||
const newToggle = realtimeToggle.cloneNode(true);
|
||
realtimeToggle.parentNode.replaceChild(newToggle, realtimeToggle);
|
||
newToggle.addEventListener('change', toggleRealtime);
|
||
}
|
||
if (refreshBtn) {
|
||
const newBtn = refreshBtn.cloneNode(true);
|
||
refreshBtn.parentNode.replaceChild(newBtn, refreshBtn);
|
||
newBtn.addEventListener('click', refreshLogs);
|
||
}
|
||
if (levelFilter) {
|
||
const newFilter = levelFilter.cloneNode(true);
|
||
levelFilter.parentNode.replaceChild(newFilter, levelFilter);
|
||
newFilter.addEventListener('change', filterLogs);
|
||
}
|
||
if (pluginFilter) {
|
||
const newFilter = pluginFilter.cloneNode(true);
|
||
pluginFilter.parentNode.replaceChild(newFilter, pluginFilter);
|
||
newFilter.addEventListener('change', filterLogs);
|
||
}
|
||
if (searchInput) {
|
||
const newInput = searchInput.cloneNode(true);
|
||
searchInput.parentNode.replaceChild(newInput, searchInput);
|
||
newInput.addEventListener('input', filterLogs);
|
||
}
|
||
if (autoscrollToggle) {
|
||
const newToggle = autoscrollToggle.cloneNode(true);
|
||
autoscrollToggle.parentNode.replaceChild(newToggle, autoscrollToggle);
|
||
newToggle.addEventListener('change', toggleAutoscroll);
|
||
}
|
||
if (clearBtn) {
|
||
const newBtn = clearBtn.cloneNode(true);
|
||
clearBtn.parentNode.replaceChild(newBtn, clearBtn);
|
||
newBtn.addEventListener('click', clearLogs);
|
||
}
|
||
if (downloadBtn) {
|
||
const newBtn = downloadBtn.cloneNode(true);
|
||
downloadBtn.parentNode.replaceChild(newBtn, downloadBtn);
|
||
newBtn.addEventListener('click', downloadLogs);
|
||
}
|
||
const currentPluginFilterBtn = document.getElementById('current-plugin-filter-btn');
|
||
if (currentPluginFilterBtn) {
|
||
const newBtn = currentPluginFilterBtn.cloneNode(true);
|
||
currentPluginFilterBtn.parentNode.replaceChild(newBtn, currentPluginFilterBtn);
|
||
newBtn.addEventListener('click', function() {
|
||
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||
if (pluginFilterEl && window._currentPluginId) {
|
||
pluginFilterEl.value = window._currentPluginId;
|
||
filterLogs();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Current-plugin poll and the realtime log stream run only while the Logs
|
||
// tab is active and the page is visible (both restart with an immediate
|
||
// refresh). Re-registering on partial reload replaces the old one.
|
||
const pluginErrorsRefreshBtn = document.getElementById('plugin-errors-refresh-btn');
|
||
if (pluginErrorsRefreshBtn) pluginErrorsRefreshBtn.onclick = refreshPluginErrors;
|
||
const pluginErrorsClearBtn = document.getElementById('plugin-errors-clear-btn');
|
||
if (pluginErrorsClearBtn) pluginErrorsClearBtn.onclick = clearPluginErrors;
|
||
|
||
function startLogsActivity() {
|
||
refreshCurrentPluginStatus();
|
||
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
|
||
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
|
||
refreshPluginErrors();
|
||
if (window._pluginErrorsPollTimer) clearInterval(window._pluginErrorsPollTimer);
|
||
window._pluginErrorsPollTimer = setInterval(refreshPluginErrors, 15000);
|
||
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
|
||
}
|
||
function stopLogsActivity() {
|
||
if (window._currentPluginPollTimer) {
|
||
clearInterval(window._currentPluginPollTimer);
|
||
window._currentPluginPollTimer = null;
|
||
}
|
||
if (window._pluginErrorsPollTimer) {
|
||
clearInterval(window._pluginErrorsPollTimer);
|
||
window._pluginErrorsPollTimer = null;
|
||
}
|
||
stopRealtimeLogs();
|
||
}
|
||
if (window.LEDVisibility) {
|
||
window.LEDVisibility.onActive('logs', startLogsActivity, stopLogsActivity);
|
||
} else {
|
||
startLogsActivity();
|
||
}
|
||
|
||
// Handle window resize for responsive height. Added once: this script
|
||
// re-runs on every partial load, and the handler reads window globals.
|
||
if (window._logsResizeListenerAdded) return;
|
||
window._logsResizeListenerAdded = true;
|
||
window.addEventListener('resize', function() {
|
||
if (window._logContainer) {
|
||
// Force a reflow to update sizing
|
||
window._logContainer.style.display = 'none';
|
||
window._logContainer.offsetHeight; // Trigger reflow
|
||
window._logContainer.style.display = '';
|
||
|
||
// Re-evaluate scroll position after resize
|
||
setTimeout(function() {
|
||
const distanceFromBottom = window._logContainer.scrollHeight - window._logContainer.scrollTop - window._logContainer.clientHeight;
|
||
window._isUserNearBottom = distanceFromBottom <= window._scrollThreshold;
|
||
}, 100);
|
||
}
|
||
});
|
||
})();
|
||
|
||
function initializeLogs() {
|
||
// Load initial logs
|
||
loadLogs();
|
||
|
||
// Setup SSE for real-time logs
|
||
setupRealtimeLogs();
|
||
|
||
// Setup auto-scroll
|
||
setupAutoscroll();
|
||
}
|
||
|
||
function loadLogs() {
|
||
showLoading();
|
||
|
||
fetch('/api/v3/logs')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
hideLoading();
|
||
|
||
if (data.status === 'success' && data.data && data.data.logs) {
|
||
processLogs(data.data.logs);
|
||
updateLogStats();
|
||
} else {
|
||
showEmptyState();
|
||
}
|
||
})
|
||
.catch(error => {
|
||
hideLoading();
|
||
showError('Failed to load logs: ' + error.message);
|
||
});
|
||
}
|
||
|
||
// Closes the log stream and cancels any pending reconnect, so a paused
|
||
// (inactive tab / hidden page) stream stays closed.
|
||
function stopRealtimeLogs() {
|
||
if (window._logsReconnectTimer) {
|
||
clearTimeout(window._logsReconnectTimer);
|
||
window._logsReconnectTimer = null;
|
||
}
|
||
if (window._logsEventSource) {
|
||
window._logsEventSource.close();
|
||
window._logsEventSource = null;
|
||
}
|
||
}
|
||
|
||
function setupRealtimeLogs() {
|
||
stopRealtimeLogs();
|
||
// Don't open (or reconnect) while the Logs tab isn't on screen;
|
||
// LEDVisibility restarts the stream when it is.
|
||
if (window.LEDVisibility && !window.LEDVisibility.isActive('logs')) return;
|
||
|
||
window._logsEventSource = new EventSource('/api/v3/stream/logs');
|
||
|
||
window._logsEventSource.onopen = function() {
|
||
document.getElementById('log-connection-status').innerHTML = `
|
||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||
<span>Connected to log stream</span>
|
||
`;
|
||
};
|
||
|
||
window._logsEventSource.onmessage = function(event) {
|
||
const data = JSON.parse(event.data);
|
||
|
||
if (data.logs && window._isRealtime) {
|
||
processLogs(data.logs, true);
|
||
updateLogStats();
|
||
|
||
// Use the new smart scroll function
|
||
scrollToBottomIfNeeded();
|
||
}
|
||
};
|
||
|
||
window._logsEventSource.onerror = function() {
|
||
document.getElementById('log-connection-status').innerHTML = `
|
||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||
<span>Disconnected from log stream</span>
|
||
`;
|
||
|
||
// Attempt to reconnect after 5 seconds (cancelled by stopRealtimeLogs)
|
||
if (window._logsReconnectTimer) clearTimeout(window._logsReconnectTimer);
|
||
window._logsReconnectTimer = setTimeout(setupRealtimeLogs, 5000);
|
||
};
|
||
}
|
||
|
||
function processLogs(logsText, append = false) {
|
||
if (!append) {
|
||
window._allLogs = [];
|
||
if (window._logsContent) {
|
||
window._logsContent.innerHTML = '';
|
||
}
|
||
|
||
// Container cleared for new logs
|
||
}
|
||
|
||
// Parse journalctl output
|
||
const lines = logsText.split('\n').filter(line => line.trim());
|
||
|
||
lines.forEach(line => {
|
||
// Skip empty lines
|
||
if (!line.trim()) return;
|
||
|
||
// journalctl (--output=short-iso) emits: "YYYY-MM-DDTHH:MM:SS+ZZZZ hostname service[pid]: message"
|
||
// Example: "2024-01-15T10:23:45+0000 raspberrypi ledmatrix[1234]: INFO - plugin.nhl_scoreboard - [Plugin: nhl_scoreboard] Updated scores"
|
||
// Also accept the older syslog-style "MMM DD HH:MM:SS" timestamp for compatibility.
|
||
|
||
let timestamp = '';
|
||
let level = 'INFO';
|
||
let message = line;
|
||
let plugin = '';
|
||
let rest = null;
|
||
|
||
const isoMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:?\d{2}|Z))\s+(.*)$/);
|
||
const syslogMatch = !isoMatch && line.match(/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+(.*)$/);
|
||
|
||
if (isoMatch) {
|
||
timestamp = isoMatch[1];
|
||
rest = isoMatch[2];
|
||
} else if (syslogMatch) {
|
||
timestamp = syslogMatch[1];
|
||
rest = syslogMatch[2];
|
||
} else {
|
||
// If no timestamp, use current time
|
||
timestamp = new Date().toLocaleString('en-US', {
|
||
month: 'short',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
hour12: false
|
||
});
|
||
}
|
||
|
||
if (rest !== null) {
|
||
// Find the message part (after hostname + service name/pid)
|
||
const messageMatch = rest.match(/:\s*(.+)$/);
|
||
if (messageMatch) {
|
||
message = messageMatch[1];
|
||
|
||
// Detect log level from message
|
||
if (message.match(/\b(ERROR|CRITICAL|FATAL)\b/i)) {
|
||
level = 'ERROR';
|
||
} else if (message.match(/\b(WARNING|WARN)\b/i)) {
|
||
level = 'WARNING';
|
||
} else if (message.match(/\bDEBUG\b/i)) {
|
||
level = 'DEBUG';
|
||
} else if (message.match(/\bINFO\b/i)) {
|
||
level = 'INFO';
|
||
}
|
||
|
||
// Clean up level prefix from message if it exists
|
||
message = message.replace(/^(ERROR|WARNING|WARN|INFO|DEBUG)\s*[-:]\s*/i, '');
|
||
|
||
// journalctl already carries its own timestamp; strip the app's
|
||
// internal "YYYY-MM-DD HH:MM:SS.mmm - LEVEL - logger.name - "
|
||
// prefix (see ContextualFormatter in src/logging_config.py) so
|
||
// it isn't duplicated in the displayed message.
|
||
message = message.replace(
|
||
/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*-\s*(ERROR|WARNING|WARN|INFO|DEBUG|CRITICAL)\s*-\s*[\w.]+\s*-\s*/i,
|
||
''
|
||
);
|
||
|
||
// Extract the "[Plugin: <id>]" context tag emitted by
|
||
// ContextualFormatter for every plugin logger (see
|
||
// src/logging_config.py PluginLoggerAdapter), and pull it out
|
||
// of the displayed message into its own field.
|
||
const pluginMatch = message.match(/^\[Plugin:\s*([^\]]+)\]\s*/);
|
||
if (pluginMatch) {
|
||
plugin = pluginMatch[1].trim();
|
||
message = message.replace(pluginMatch[0], '');
|
||
}
|
||
} else {
|
||
message = rest;
|
||
}
|
||
}
|
||
|
||
const logEntry = {
|
||
timestamp: timestamp,
|
||
level: level,
|
||
plugin: plugin,
|
||
message: message,
|
||
raw: line,
|
||
id: Date.now() + Math.random()
|
||
};
|
||
|
||
// Don't add duplicate entries when appending
|
||
if (!append || !window._allLogs.find(log => log.raw === line)) {
|
||
window._allLogs.push(logEntry);
|
||
}
|
||
});
|
||
|
||
// Trim logs if we exceed the maximum
|
||
if (window._allLogs.length > window._MAX_LOGS) {
|
||
window._allLogs = window._allLogs.slice(-window._MAX_LOGS);
|
||
}
|
||
|
||
updatePluginFilterOptions();
|
||
filterLogs();
|
||
}
|
||
|
||
function updatePluginFilterOptions() {
|
||
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||
if (!pluginFilterEl) return;
|
||
|
||
const previousValue = pluginFilterEl.value;
|
||
const plugins = Array.from(new Set(window._allLogs.map(log => log.plugin).filter(Boolean))).sort();
|
||
|
||
pluginFilterEl.innerHTML = '<option value="">All Plugins</option>' +
|
||
plugins.map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
|
||
|
||
// Restore previous selection if it's still a valid option
|
||
if (previousValue && plugins.includes(previousValue)) {
|
||
pluginFilterEl.value = previousValue;
|
||
}
|
||
}
|
||
|
||
function renderLogs() {
|
||
if (window._filteredLogs.length === 0) {
|
||
showEmptyState();
|
||
return;
|
||
}
|
||
|
||
showLogs();
|
||
|
||
if (window._logsContent) {
|
||
window._logsContent.innerHTML = '';
|
||
}
|
||
|
||
window._filteredLogs.forEach(log => {
|
||
const logElement = document.createElement('div');
|
||
logElement.className = `log-entry py-1 px-2 hover:bg-gray-800 rounded transition-colors duration-150 ${getLogLevelClass(log.level)}`;
|
||
const pluginBadge = log.plugin
|
||
? `<span class="log-plugin flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold bg-purple-700 text-white" title="Plugin: ${escapeHtml(log.plugin)}">${escapeHtml(log.plugin)}</span>`
|
||
: '';
|
||
logElement.innerHTML = `
|
||
<div class="flex items-start gap-3 text-xs font-mono">
|
||
<span class="log-timestamp text-gray-400 flex-shrink-0 w-32">${escapeHtml(log.timestamp)}</span>
|
||
<span class="log-level flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${getLogLevelBadgeClass(log.level)}">${log.level}</span>
|
||
${pluginBadge}
|
||
<span class="log-message flex-1 ${getLogLevelTextClass(log.level)} break-words">${escapeHtml(log.message)}</span>
|
||
</div>
|
||
`;
|
||
if (window._logsContent) {
|
||
window._logsContent.appendChild(logElement);
|
||
}
|
||
});
|
||
}
|
||
|
||
function getLogLevelClass(level) {
|
||
// Background color for the entire log entry row
|
||
const classes = {
|
||
'ERROR': 'log-level-error',
|
||
'WARNING': 'log-level-warning',
|
||
'INFO': '',
|
||
'DEBUG': 'log-level-debug'
|
||
};
|
||
return classes[level] || '';
|
||
}
|
||
|
||
function getLogLevelBadgeClass(level) {
|
||
const classes = {
|
||
'ERROR': 'bg-red-600 text-white',
|
||
'WARNING': 'bg-yellow-600 text-white',
|
||
'INFO': 'bg-blue-600 text-white',
|
||
'DEBUG': 'bg-gray-600 text-white'
|
||
};
|
||
return classes[level] || 'bg-gray-600 text-white';
|
||
}
|
||
|
||
function getLogLevelTextClass(level) {
|
||
const classes = {
|
||
'ERROR': 'text-red-300',
|
||
'WARNING': 'text-yellow-300',
|
||
'INFO': 'text-gray-200',
|
||
'DEBUG': 'text-gray-400'
|
||
};
|
||
return classes[level] || 'text-gray-300';
|
||
}
|
||
|
||
function filterLogs() {
|
||
const levelFilterEl = document.getElementById('log-level-filter');
|
||
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||
const searchEl = document.getElementById('log-search');
|
||
if (!levelFilterEl || !searchEl) return;
|
||
|
||
const levelFilter = levelFilterEl.value;
|
||
const pluginFilter = pluginFilterEl ? pluginFilterEl.value : '';
|
||
const searchTerm = searchEl.value.toLowerCase();
|
||
|
||
window._filteredLogs = window._allLogs.filter(log => {
|
||
// Level filter
|
||
if (levelFilter) {
|
||
const levels = {
|
||
'ERROR': ['ERROR'],
|
||
'WARNING': ['ERROR', 'WARNING'],
|
||
'INFO': ['ERROR', 'WARNING', 'INFO']
|
||
};
|
||
|
||
if (!levels[levelFilter].includes(log.level)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Plugin filter
|
||
if (pluginFilter && log.plugin !== pluginFilter) {
|
||
return false;
|
||
}
|
||
|
||
// Search filter
|
||
if (searchTerm && !log.message.toLowerCase().includes(searchTerm) &&
|
||
!(log.plugin && log.plugin.toLowerCase().includes(searchTerm))) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
renderLogs();
|
||
updateLogStats();
|
||
}
|
||
|
||
function toggleRealtime() {
|
||
const toggleEl = document.getElementById('log-realtime-toggle');
|
||
if (!toggleEl) return;
|
||
|
||
window._isRealtime = toggleEl.checked;
|
||
|
||
if (window._isRealtime) {
|
||
setupRealtimeLogs();
|
||
} else if (window._logsEventSource) {
|
||
window._logsEventSource.close();
|
||
window._logsEventSource = null;
|
||
}
|
||
}
|
||
|
||
function refreshLogs() {
|
||
loadLogs();
|
||
if (typeof showNotification !== 'undefined') {
|
||
showNotification('Logs refreshed', 'success');
|
||
}
|
||
}
|
||
|
||
function clearLogs() {
|
||
window._allLogs = [];
|
||
window._filteredLogs = [];
|
||
if (window._logsContent) {
|
||
window._logsContent.innerHTML = '';
|
||
}
|
||
showEmptyState();
|
||
updateLogStats();
|
||
if (typeof showNotification !== 'undefined') {
|
||
showNotification('Logs cleared', 'info');
|
||
}
|
||
}
|
||
|
||
function downloadLogs() {
|
||
if (window._filteredLogs.length === 0) {
|
||
if (typeof showNotification !== 'undefined') {
|
||
showNotification('No logs to download', 'warning');
|
||
}
|
||
return;
|
||
}
|
||
|
||
const logText = window._filteredLogs.map(log => log.raw).join('\n');
|
||
const blob = new Blob([logText], { type: 'text/plain' });
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `ledmatrix-logs-${new Date().toISOString().slice(0, 19)}.txt`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
if (typeof showNotification !== 'undefined') {
|
||
showNotification('Logs downloaded', 'success');
|
||
}
|
||
}
|
||
|
||
// Track if user is near bottom for smart auto-scroll
|
||
if (typeof window._isUserNearBottom === 'undefined') {
|
||
window._isUserNearBottom = true;
|
||
}
|
||
if (typeof window._scrollThreshold === 'undefined') {
|
||
window._scrollThreshold = 100; // pixels from bottom to consider "near bottom"
|
||
}
|
||
|
||
function setupAutoscroll() {
|
||
if (!window._logsContent) return;
|
||
|
||
const observer = new MutationObserver(function() {
|
||
// Use requestAnimationFrame for better timing
|
||
requestAnimationFrame(scrollToBottomIfNeeded);
|
||
});
|
||
observer.observe(window._logsContent, { childList: true });
|
||
|
||
// Also listen for manual scroll events to detect when user is not at bottom
|
||
if (window._logContainer) {
|
||
window._logContainer.addEventListener('scroll', function() {
|
||
const distanceFromBottom = window._logContainer.scrollHeight - window._logContainer.scrollTop - window._logContainer.clientHeight;
|
||
window._isUserNearBottom = distanceFromBottom <= window._scrollThreshold;
|
||
});
|
||
}
|
||
}
|
||
|
||
function scrollToBottomIfNeeded() {
|
||
const autoscrollEl = document.getElementById('log-autoscroll');
|
||
if (autoscrollEl && autoscrollEl.checked && window._logContainer && window._isUserNearBottom) {
|
||
// Use requestAnimationFrame for smooth scrolling
|
||
requestAnimationFrame(function() {
|
||
// Remember current scroll position
|
||
const previousScrollHeight = window._logContainer.scrollHeight;
|
||
|
||
// Scroll to bottom
|
||
window._logContainer.scrollTop = window._logContainer.scrollHeight;
|
||
|
||
// Ensure we're actually at the bottom after a brief delay
|
||
setTimeout(function() {
|
||
if (window._logContainer.scrollTop + window._logContainer.clientHeight >= window._logContainer.scrollHeight - 10) {
|
||
window._isUserNearBottom = true;
|
||
}
|
||
}, 50);
|
||
});
|
||
}
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
// Legacy function for backward compatibility
|
||
if (window._logContainer) {
|
||
window._logContainer.scrollTop = window._logContainer.scrollHeight;
|
||
window._isUserNearBottom = true;
|
||
}
|
||
}
|
||
|
||
function toggleAutoscroll() {
|
||
const autoscrollEl = document.getElementById('log-autoscroll');
|
||
if (!autoscrollEl || !autoscrollEl.checked) {
|
||
// Don't auto-scroll if unchecked or element doesn't exist
|
||
window._isUserNearBottom = false;
|
||
return;
|
||
}
|
||
|
||
// Scroll to bottom when re-enabled and user was near bottom
|
||
if (window._isUserNearBottom) {
|
||
scrollToBottom();
|
||
}
|
||
}
|
||
|
||
function updateLogStats() {
|
||
const stats = document.getElementById('log-stats');
|
||
const count = document.getElementById('log-count');
|
||
|
||
if (stats && count) {
|
||
count.textContent = window._filteredLogs.length;
|
||
stats.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
function showLoading() {
|
||
document.getElementById('logs-loading').classList.remove('hidden');
|
||
document.getElementById('logs-display').classList.add('hidden');
|
||
document.getElementById('logs-empty').classList.add('hidden');
|
||
}
|
||
|
||
function showLogs() {
|
||
document.getElementById('logs-loading').classList.add('hidden');
|
||
document.getElementById('logs-display').classList.remove('hidden');
|
||
document.getElementById('logs-empty').classList.add('hidden');
|
||
}
|
||
|
||
function hideLoading() {
|
||
document.getElementById('logs-loading').classList.add('hidden');
|
||
}
|
||
|
||
function showEmptyState() {
|
||
document.getElementById('logs-loading').classList.add('hidden');
|
||
document.getElementById('logs-display').classList.add('hidden');
|
||
document.getElementById('logs-empty').classList.remove('hidden');
|
||
document.getElementById('log-stats').classList.add('hidden');
|
||
}
|
||
|
||
function showError(message) {
|
||
if (window._logsContent) {
|
||
window._logsContent.innerHTML = `<div class="text-red-400 p-4">${escapeHtml(message)}</div>`;
|
||
window._logsContent.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
// Utility function to escape HTML. Quotes too: the result is interpolated into
|
||
// quoted attribute values (option value=, title=), and the
|
||
// textContent/innerHTML round-trip only escapes &, < and >.
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function refreshCurrentPluginStatus() {
|
||
fetch('/api/v3/display/current-status')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.status !== 'success' || !data.data) return;
|
||
const state = data.data;
|
||
const banner = document.getElementById('current-plugin-banner');
|
||
const modeEl = document.getElementById('current-plugin-mode');
|
||
const idWrap = document.getElementById('current-plugin-id-wrap');
|
||
const idEl = document.getElementById('current-plugin-id');
|
||
const filterBtn = document.getElementById('current-plugin-filter-btn');
|
||
if (!banner || !modeEl) return;
|
||
|
||
window._currentPluginId = state.plugin_id || null;
|
||
|
||
modeEl.textContent = state.mode || 'unknown';
|
||
if (state.plugin_id) {
|
||
idEl.textContent = state.plugin_id;
|
||
idWrap.classList.remove('hidden');
|
||
if (filterBtn) filterBtn.classList.remove('hidden');
|
||
} else {
|
||
idWrap.classList.add('hidden');
|
||
if (filterBtn) filterBtn.classList.add('hidden');
|
||
}
|
||
banner.classList.remove('hidden');
|
||
})
|
||
.catch(() => {
|
||
// Silently ignore - banner just stays hidden/stale
|
||
});
|
||
}
|
||
|
||
// Plugin errors panel. The display service records them and publishes a
|
||
// snapshot every few seconds; /api/v3/errors/summary serves that snapshot.
|
||
// Plugin ids and messages come from plugins: everything goes through escapeHtml.
|
||
function formatErrorTime(iso) {
|
||
return iso ? String(iso).replace('T', ' ').slice(0, 19) : '';
|
||
}
|
||
|
||
function renderPluginErrors(summary) {
|
||
const body = document.getElementById('plugin-errors-body');
|
||
const meta = document.getElementById('plugin-errors-meta');
|
||
if (!body) return;
|
||
|
||
if (meta) {
|
||
let text = 'Recorded by the display service';
|
||
if (summary.generated_at) text += ' · updated ' + formatErrorTime(summary.generated_at);
|
||
if (summary.clear_pending) text += ' · clearing…';
|
||
meta.textContent = text;
|
||
}
|
||
|
||
if (!summary.snapshot_available) {
|
||
body.innerHTML = '<p class="text-gray-600"><i class="fas fa-hourglass-half mr-1"></i>' +
|
||
'Display service hasn’t reported yet. Errors appear here once it is running.</p>';
|
||
return;
|
||
}
|
||
const plugins = Object.entries(summary.plugin_error_counts || {}).map(([id, types]) => {
|
||
const total = Object.values(types || {}).reduce((sum, n) => sum + (Number(n) || 0), 0);
|
||
return { id, types: types || {}, total };
|
||
}).sort((a, b) => b.total - a.total);
|
||
const patterns = Object.values(summary.active_patterns || {});
|
||
|
||
if (!Number(summary.total_errors) && plugins.length === 0 && patterns.length === 0) {
|
||
body.innerHTML = '<p class="text-gray-600"><i class="fas fa-check-circle text-green-600 mr-1"></i>' +
|
||
'No plugin errors recorded</p>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
if (plugins.length) {
|
||
html += '<div class="overflow-x-auto"><table class="min-w-full text-sm">' +
|
||
'<thead><tr class="text-left text-xs uppercase text-gray-500 border-b border-gray-200">' +
|
||
'<th class="py-1 pr-4 font-medium">Plugin</th><th class="py-1 pr-4 font-medium">Errors</th>' +
|
||
'<th class="py-1 font-medium">Types</th></tr></thead><tbody>';
|
||
plugins.forEach(p => {
|
||
const types = Object.entries(p.types)
|
||
.map(([type, n]) => `${escapeHtml(type)} ×${escapeHtml(String(Number(n) || 0))}`)
|
||
.join(', ');
|
||
html += '<tr class="border-b border-gray-100">' +
|
||
`<td class="py-1 pr-4"><code class="bg-gray-100 px-1 rounded">${escapeHtml(p.id)}</code></td>` +
|
||
`<td class="py-1 pr-4 font-semibold text-gray-900">${escapeHtml(String(p.total))}</td>` +
|
||
`<td class="py-1 text-gray-700 break-words">${types}</td></tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
if (patterns.length) {
|
||
const badge = {
|
||
critical: 'bg-red-100 text-red-800',
|
||
error: 'bg-amber-100 text-amber-800',
|
||
warning: 'bg-yellow-100 text-yellow-800'
|
||
};
|
||
html += '<h4 class="mt-4 mb-2 text-sm font-semibold text-gray-900">Repeating errors</h4><ul class="list-none pl-0 space-y-2">';
|
||
patterns.forEach(p => {
|
||
const severity = String(p.severity || 'warning');
|
||
const affected = (p.affected_plugins || []).map(id => escapeHtml(id)).join(', ') || 'unknown';
|
||
const sample = (p.sample_messages || [])[0];
|
||
html += '<li class="border border-gray-200 rounded-lg px-3 py-2">' +
|
||
'<div class="flex flex-wrap items-center gap-2">' +
|
||
`<span class="px-2 py-0.5 rounded text-xs font-semibold ${badge[severity] || badge.warning}">${escapeHtml(severity)}</span>` +
|
||
`<code class="font-semibold text-gray-900">${escapeHtml(p.error_type || '')}</code>` +
|
||
`<span class="text-gray-700">×${escapeHtml(String(Number(p.count) || 0))}</span>` +
|
||
`<span class="text-xs text-gray-500 ml-auto">last seen ${escapeHtml(formatErrorTime(p.last_seen))}</span></div>` +
|
||
`<div class="mt-1 text-xs text-gray-600">Plugins: ${affected}</div>` +
|
||
(sample ? `<div class="mt-1 text-xs font-mono text-gray-700 break-words">${escapeHtml(sample)}</div>` : '') +
|
||
'</li>';
|
||
});
|
||
html += '</ul>';
|
||
}
|
||
body.innerHTML = html;
|
||
}
|
||
|
||
function refreshPluginErrors() {
|
||
fetch('/api/v3/errors/summary')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.status !== 'success' || !data.data) throw new Error(data.message || 'Request failed');
|
||
renderPluginErrors(data.data);
|
||
})
|
||
.catch(() => {
|
||
const body = document.getElementById('plugin-errors-body');
|
||
if (body) body.textContent = 'Could not load plugin errors.';
|
||
});
|
||
}
|
||
|
||
function clearPluginErrors() {
|
||
if (!confirm('Clear all recorded plugin errors?')) return;
|
||
fetch('/api/v3/errors/clear', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ all: true })
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (typeof showNotification !== 'undefined') {
|
||
showNotification(data.status === 'success' ? 'Plugin errors cleared'
|
||
: (data.message || 'Could not clear plugin errors'),
|
||
data.status === 'success' ? 'success' : 'error');
|
||
}
|
||
refreshPluginErrors();
|
||
})
|
||
.catch(() => {
|
||
if (typeof showNotification !== 'undefined') showNotification('Could not clear plugin errors', 'error');
|
||
});
|
||
}
|
||
|
||
// Cleanup on page unload
|
||
window.addEventListener('beforeunload', function() {
|
||
if (window._logsEventSource) {
|
||
window._logsEventSource.close();
|
||
}
|
||
if (window._currentPluginPollTimer) {
|
||
clearInterval(window._currentPluginPollTimer);
|
||
window._currentPluginPollTimer = null;
|
||
}
|
||
if (window._pluginErrorsPollTimer) {
|
||
clearInterval(window._pluginErrorsPollTimer);
|
||
window._pluginErrorsPollTimer = null;
|
||
}
|
||
});
|
||
</script>
|