mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
- Plugin-supplied widgets load as /static/plugin-widgets/...js?v=<plugin version>, so an update isn't hidden behind the year-long immutable cache. - Fire-and-forget loadInstalledPlugins() calls catch the rejection it has already reported, so the global handler no longer adds a second toast. - Timezone picker renders again when the General partial is re-injected. - Remove dead code: executePluginAction's six plugin-id fallbacks and [DEBUG] logging, window.currentPluginConfig and every read of it, the file-upload JSON delete branch, unused PluginAPI / PluginInstallManager / PluginStateManager helpers, loadPluginWidgetsFromManifest, the stale install_manager.js and LEDVisibility fallbacks, error_handler.js's global escapeHtml, 13 unused CSS rules, and stale comments/no-op returns. - pytz < 2027, psutil < 7 in requirements-test.txt, pytest-cov < 8. - Pin anthropics/claude-code-action to the commit v1 resolves to. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
930 lines
36 KiB
HTML
930 lines
36 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>
|
||
// Scoped: this script runs at global scope after every HTMX swap, and the
|
||
// Cache partial has helpers with the same names (showError, escapeHtml). State
|
||
// that must survive a reload of this partial lives on window._logs* instead.
|
||
(function() {
|
||
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();
|
||
}
|
||
window.LEDVisibility.onActive('logs', startLogsActivity, stopLogsActivity);
|
||
|
||
// 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();
|
||
showNotification('Logs refreshed', 'success');
|
||
}
|
||
|
||
function clearLogs() {
|
||
window._allLogs = [];
|
||
window._filteredLogs = [];
|
||
if (window._logsContent) {
|
||
window._logsContent.innerHTML = '';
|
||
}
|
||
showEmptyState();
|
||
updateLogStats();
|
||
showNotification('Logs cleared', 'info');
|
||
}
|
||
|
||
function downloadLogs() {
|
||
if (window._filteredLogs.length === 0) {
|
||
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);
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||
|
||
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 => {
|
||
showNotification(data.status === 'success' ? 'Plugin errors cleared'
|
||
: (data.message || 'Could not clear plugin errors'),
|
||
data.status === 'success' ? 'success' : 'error');
|
||
refreshPluginErrors();
|
||
})
|
||
.catch(() => {
|
||
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>
|