/** * Modern Log Tail Monitor Application * Real-time log monitoring with Server-Sent Events */ class LogTailMonitor { constructor() { this.eventSource = null; this.isConnected = false; this.isPaused = false; this.autoScroll = true; this.currentFile = null; this.logLines = []; this.visibleLines = 0; this.totalLines = 0; this.filterText = ''; this.highlightText = ''; this.elements = {}; this.lastUpdateTime = null; this.init(); } /** * Initialize the application */ async init() { this.cacheElements(); this.bindEvents(); await this.loadFiles(); this.checkUrlParams(); // Set initial connection status as disconnected this.setConnectionStatus(false); } /** * Cache DOM elements for better performance */ cacheElements() { this.elements = { // Navigation connectionStatus: document.getElementById('connection-status'), currentFileDisplay: document.getElementById('current-file'), // Sidebar fileList: document.getElementById('file-list'), refreshFilesBtn: document.getElementById('refresh-files'), // Controls autoScrollToggle: document.getElementById('auto-scroll'), filterInput: document.getElementById('filter-input'), highlightInput: document.getElementById('highlight-input'), clearFilterBtn: document.getElementById('clear-filter'), clearHighlightBtn: document.getElementById('clear-highlight'), pauseBtn: document.getElementById('pause-btn'), clearLogsBtn: document.getElementById('clear-logs'), exportLogsBtn: document.getElementById('export-logs'), // Log display logOutput: document.getElementById('log-output'), welcomeMessage: document.getElementById('welcome-message'), lineCount: document.getElementById('line-count'), visibleCount: document.getElementById('visible-count'), lastUpdateDisplay: document.getElementById('last-update-time'), // Loading loadingOverlay: document.getElementById('loading-overlay') }; } /** * Bind event listeners */ bindEvents() { // File refresh this.elements.refreshFilesBtn.addEventListener('click', () => this.loadFiles()); // Controls this.elements.autoScrollToggle.addEventListener('change', (e) => { this.autoScroll = e.target.checked; }); this.elements.filterInput.addEventListener('input', (e) => { this.filterText = e.target.value; this.applyFilters(); }); this.elements.highlightInput.addEventListener('input', (e) => { this.highlightText = e.target.value; this.applyFilters(); }); this.elements.clearFilterBtn.addEventListener('click', () => { this.elements.filterInput.value = ''; this.filterText = ''; this.applyFilters(); }); this.elements.clearHighlightBtn.addEventListener('click', () => { this.elements.highlightInput.value = ''; this.highlightText = ''; this.applyFilters(); }); this.elements.pauseBtn.addEventListener('click', () => this.togglePause()); this.elements.clearLogsBtn.addEventListener('click', () => this.clearLogs()); this.elements.exportLogsBtn.addEventListener('click', () => this.exportLogs()); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey) { switch (e.key) { case 'f': e.preventDefault(); this.elements.filterInput.focus(); break; case 'h': e.preventDefault(); this.elements.highlightInput.focus(); break; case 'p': e.preventDefault(); this.togglePause(); break; case 'l': e.preventDefault(); this.clearLogs(); break; case 's': e.preventDefault(); this.exportLogs(); break; } } }); } /** * Check URL parameters for initial file selection */ checkUrlParams() { const params = new URLSearchParams(window.location.search); const file = params.get('file'); if (file) { this.startTailing(file); } } /** * Load available files from the server */ async loadFiles() { try { this.elements.fileList.innerHTML = '
Loading files...
'; // First check server health const healthResponse = await fetch('api/health'); if (healthResponse.ok) { const healthData = await healthResponse.json(); console.log('Server status:', healthData); } const response = await fetch('api/files'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); if (data.status === 'ok' && Array.isArray(data.files)) { this.renderFileList(data.files); // Set connection status to connected when we can load files this.setConnectionStatus(true); } else { throw new Error('Invalid response format'); } } catch (error) { console.error('Failed to load files:', error); this.elements.fileList.innerHTML = `
Failed to load files: ${error.message}
`; // Set connection status to disconnected when we can't load files this.setConnectionStatus(false); } } /** * Render the file list in the sidebar */ renderFileList(files) { if (files.length === 0) { this.elements.fileList.innerHTML = `
No files found
`; return; } this.elements.fileList.innerHTML = ''; files.forEach(file => { const fileItem = document.createElement('div'); fileItem.className = 'file-item'; fileItem.innerHTML = ` ${this.escapeHtml(file.name)} `; fileItem.addEventListener('click', (e) => { e.preventDefault(); // Remove active class from all items document.querySelectorAll('.file-item').forEach(item => { item.classList.remove('active'); }); // Add active class to clicked item fileItem.classList.add('active'); // Start tailing the selected file this.startTailing(file.name); history.pushState(null, '', `?file=${encodeURIComponent(file.name)}`); }); this.elements.fileList.appendChild(fileItem); }); } /** * Start tailing a specific file */ async startTailing(filename, isReconnect = false) { this.showLoading('Connecting to ' + filename + '...'); // Stop current connection this.stopTailing(); // Update UI this.currentFile = filename; this.elements.currentFileDisplay.textContent = filename; this.elements.welcomeMessage.style.display = 'none'; // Update active file in sidebar document.querySelectorAll('.file-item').forEach(item => { item.classList.remove('active'); const itemText = item.querySelector('span').textContent; if (itemText === filename) { item.classList.add('active'); } }); // Clear existing logs only if it's not a reconnection if (!isReconnect) { this.clearLogs(); } else { // Add a reconnection indicator to the existing logs this.addReconnectionMessage(); } try { // Start SSE connection with reconnect parameter if needed const url = `api/files/${encodeURIComponent(filename)}${isReconnect ? '?reconnect=true' : ''}`; this.eventSource = new EventSource(url); this.eventSource.onopen = () => { this.hideLoading(); this.setConnectionStatus(true); console.log('SSE connection established'); // If this is a reconnection, add a success message if (isReconnect) { this.addReconnectionSuccessMessage(); } }; this.eventSource.onmessage = (event) => { if (!this.isPaused) { this.handleLogMessage(event.data); } }; this.eventSource.onerror = (error) => { console.error('SSE Error:', error); this.setConnectionStatus(false); this.hideLoading(); // Show error message to user if (this.eventSource.readyState === EventSource.CLOSED) { console.log('SSE connection closed'); } // Auto-reconnect after delay, but don't clear logs setTimeout(() => { if (this.currentFile && this.eventSource.readyState === EventSource.CLOSED) { console.log('Attempting to reconnect...'); this.startTailing(this.currentFile, true); // true = isReconnect } }, 5000); }; } catch (error) { console.error('Failed to start tailing:', error); this.hideLoading(); this.setConnectionStatus(false); } } /** * Stop the current SSE connection */ stopTailing() { if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } this.setConnectionStatus(false); } /** * Handle incoming log messages */ handleLogMessage(data) { if (!data || data.length === 0) return; // Update last update time this.lastUpdateTime = new Date(); this.updateLastUpdateDisplay(); // Process the log data const processedData = this.processLogData(data); // Create log line element const logLine = this.createLogLine(processedData); // Add to logs array this.logLines.push(logLine); this.totalLines++; // Remove old lines if too many if (this.logLines.length > 1000) { const removedLine = this.logLines.shift(); if (removedLine.parentNode) { removedLine.parentNode.removeChild(removedLine); } } // Apply filters to the new line this.applyFiltersToLine(logLine); // Add to DOM this.elements.logOutput.appendChild(logLine); // Auto scroll if enabled if (this.autoScroll) { this.scrollToBottom(); } // Update stats this.updateStats(); } /** * Process raw log data */ processLogData(data) { // Clean up escape sequences and unicode return data .replace(/\\x22/gim, '"') .replace(/\\x5c/gim, '\\') .replace(/\\u[a-fA-F0-9]{4}/gm, (match) => String.fromCodePoint('0x' + match.toLowerCase().replace(/\\u/, '')) ) .replace(/\\x[a-fA-F0-9]{2}/gm, (match) => String.fromCharCode(parseInt(match.substr(2, 2), 16)) ); } /** * Create a log line element */ createLogLine(content) { const logLine = document.createElement('div'); logLine.className = 'log-line'; logLine.textContent = content; // Classify log level this.classifyLogLine(logLine, content); // Add click handler for expansion logLine.addEventListener('click', () => { logLine.classList.toggle('expanded'); this.elements.autoScrollToggle.checked = false; this.autoScroll = false; // Auto-collapse after 20 seconds setTimeout(() => { logLine.classList.remove('expanded'); this.elements.autoScrollToggle.checked = true; this.autoScroll = true; }, 20000); }); return logLine; } /** * Classify log line by content patterns */ classifyLogLine(element, content) { // Success patterns (2xx HTTP status codes) if (content.match(/"Mozilla\//) && content.match(/\" 2[0-9]{2} [0-9]/)) { element.classList.add('success'); } // Error patterns (non-2xx HTTP status codes) else if ((content.match(/ HTTP\//) && !content.match(/\" 2[0-9]{2} /)) || content.match(/(UDP|TCP)+ [^2]{1}[0-9]{2} \"/)) { element.classList.add('error'); } // Info patterns (social media bots) else if (content.match(/("PeerTube\/|"Friendica|"Misskey\/|"Pleroma|\(Mastodon\/)/)) { element.classList.add('info'); } // Warning patterns else if (content.toLowerCase().includes('warning') || content.toLowerCase().includes('warn')) { element.classList.add('warning'); } } /** * Apply filters to all log lines */ applyFilters() { this.logLines.forEach(line => this.applyFiltersToLine(line)); this.updateStats(); } /** * Apply filters to a single log line */ applyFiltersToLine(line) { const content = line.textContent.toLowerCase(); const filterLower = this.filterText.toLowerCase(); const highlightLower = this.highlightText.toLowerCase(); // Apply filter if (filterLower && !content.includes(filterLower)) { line.classList.add('hidden'); } else { line.classList.remove('hidden'); } // Apply highlight if (highlightLower && content.includes(highlightLower)) { line.classList.add('highlighted'); } else { line.classList.remove('highlighted'); } } /** * Toggle pause state */ togglePause() { this.isPaused = !this.isPaused; const icon = this.elements.pauseBtn.querySelector('i'); const text = this.elements.pauseBtn.querySelector('span'); if (this.isPaused) { icon.className = 'fas fa-play'; text.textContent = 'Resume'; this.elements.pauseBtn.classList.add('btn-warning'); } else { icon.className = 'fas fa-pause'; text.textContent = 'Pause'; this.elements.pauseBtn.classList.remove('btn-warning'); } } /** * Clear all log lines */ clearLogs() { this.elements.logOutput.innerHTML = ''; this.logLines = []; this.totalLines = 0; this.updateStats(); } /** * Export filtered logs */ exportLogs() { const visibleLines = this.logLines .filter(line => !line.classList.contains('hidden')) .map(line => line.textContent) .join('\n'); if (!visibleLines) { alert('No logs to export'); return; } const blob = new Blob([visibleLines], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${this.currentFile || 'logs'}_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } /** * Update connection status indicator */ setConnectionStatus(connected) { this.isConnected = connected; const status = this.elements.connectionStatus; if (connected) { status.className = 'status-indicator connected'; status.innerHTML = 'Connected'; } else { status.className = 'status-indicator disconnected'; status.innerHTML = 'Disconnected'; } } /** * Update statistics display */ updateStats() { this.visibleLines = this.logLines.filter(line => !line.classList.contains('hidden')).length; this.elements.lineCount.textContent = this.totalLines; this.elements.visibleCount.textContent = this.visibleLines; } /** * Update last update time display */ updateLastUpdateDisplay() { if (this.lastUpdateTime) { const timeStr = this.lastUpdateTime.toLocaleTimeString(); this.elements.lastUpdateDisplay.innerHTML = `Last update: ${timeStr}`; } } /** * Scroll to bottom of log container */ scrollToBottom() { this.elements.logOutput.scrollTop = this.elements.logOutput.scrollHeight; } /** * Show loading overlay */ showLoading(message = 'Loading...') { const overlay = this.elements.loadingOverlay; const spinner = overlay.querySelector('.loading-spinner span'); spinner.textContent = message; overlay.classList.add('visible'); } /** * Hide loading overlay */ hideLoading() { this.elements.loadingOverlay.classList.remove('visible'); } /** * Add a reconnection message to the log */ addReconnectionMessage() { const reconnectLine = document.createElement('div'); reconnectLine.className = 'log-line reconnect-message'; reconnectLine.innerHTML = ' Reconnecting to log stream...'; this.elements.logOutput.appendChild(reconnectLine); if (this.autoScroll) { this.scrollToBottom(); } } /** * Add a successful reconnection message to the log */ addReconnectionSuccessMessage() { const successLine = document.createElement('div'); successLine.className = 'log-line reconnect-success'; successLine.innerHTML = ' Successfully reconnected - continuing log stream...'; this.elements.logOutput.appendChild(successLine); if (this.autoScroll) { this.scrollToBottom(); } } /** * Escape HTML to prevent XSS */ escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } // Initialize the application when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.logMonitor = new LogTailMonitor(); }); // Handle page visibility changes - attempt reconnection instead of reload document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && window.logMonitor) { // Try to reconnect to current file if connection was lost if (window.logMonitor.currentFile && (!window.logMonitor.eventSource || window.logMonitor.eventSource.readyState === EventSource.CLOSED)) { console.log('Page became visible, attempting to reconnect...'); setTimeout(() => { window.logMonitor.startTailing(window.logMonitor.currentFile, true); }, 1000); } } });