Files
LEDMatrix/web_interface/templates/v3/partials/logs.html
T
ChuckandClaude Opus 5.5 0e9e2cabba fix(web): widget cache-busting, dead frontend code, and dependency pins (#656)
- 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>
2026-09-28 10:40:52 -04:00

930 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>