/** * qBit Manage Web UI - Log Viewer Component * Real-time log viewing and management */ import { API } from '../api.js'; import { showToast } from '../utils/toast.js'; import { show, hide } from '../utils/dom.js'; class LogViewer { constructor(options = {}) { this.container = options.container; this.autoRefreshInterval = parseInt(localStorage.getItem('qbm-log-refresh-interval') || '0'); // Default to 0 (no auto-refresh) this.autoRefreshTimer = null; this.currentLogFile = localStorage.getItem('qbm-selected-log-file') || 'qbit_manage.log'; // Default log file this.currentLogLimit = parseInt(localStorage.getItem('qbm-log-limit') || '50'); // Default to 50 lines this.api = new API(); this.logs = []; this.filteredLogs = []; } async init() { this.render(); this.bindEvents(); await this.loadLogFiles(); // Load log files first this.loadRecentLogs(); this.startAutoRefresh(); // Start auto-refresh on init // Initial call to handle scroll to set button visibility setTimeout(() => this.handleScroll(), 100); } async loadLogFiles() { try { const response = await this.api.getLogFiles(); const logFileSelect = this.container.querySelector('#log-file-select'); logFileSelect.innerHTML = ''; // Clear existing options if (response.log_files && response.log_files.length > 0) { response.log_files.forEach(file => { const option = document.createElement('option'); option.value = file; option.textContent = file; logFileSelect.appendChild(option); }); // Set the selected value based on localStorage or default logFileSelect.value = this.currentLogFile; if (!logFileSelect.value) { // If the stored value isn't in the list, default to the first this.currentLogFile = response.log_files[0]; logFileSelect.value = this.currentLogFile; localStorage.setItem('qbm-selected-log-file', this.currentLogFile); } } else { logFileSelect.innerHTML = ''; this.currentLogFile = null; } } catch (error) { this.showToast('Failed to load log files', 'error'); this.currentLogFile = null; } } render() { if (!this.container) return; const generatedHtml = `

System Logs

📋
No logs to display
Logs will appear here when available
`; this.container.innerHTML = generatedHtml; } bindEvents() { if (!this.container) return; // Filter controls const logFileSelect = this.container.querySelector('#log-file-select'); const searchInput = this.container.querySelector('#log-search'); const logLimitSelect = this.container.querySelector('#log-limit-select'); logFileSelect.addEventListener('change', (e) => { this.currentLogFile = e.target.value; localStorage.setItem('qbm-selected-log-file', this.currentLogFile); this.loadRecentLogs(); // Reload logs for the newly selected file }); searchInput.addEventListener('input', (e) => { this.searchTerm = e.target.value.toLowerCase(); this.applyFilters(); }); if (logLimitSelect) { // Always set the dropdown value to match currentLogLimit logLimitSelect.value = this.currentLogLimit; logLimitSelect.addEventListener('change', (e) => { this.currentLogLimit = parseInt(e.target.value); localStorage.setItem('qbm-log-limit', this.currentLogLimit); this.loadRecentLogs(); // Reload logs with new limit }); } // Manual refresh button const refreshButton = this.container.querySelector('#refresh-logs-btn'); if (refreshButton) { refreshButton.addEventListener('click', () => { this.loadRecentLogs(); this.showToast('Logs refreshed', 'info'); }); } // Auto-refresh interval control const refreshIntervalSelect = this.container.querySelector('#log-refresh-interval'); if (refreshIntervalSelect) { refreshIntervalSelect.value = this.autoRefreshInterval; // Set initial value refreshIntervalSelect.addEventListener('change', (e) => { this.autoRefreshInterval = parseInt(e.target.value); localStorage.setItem('qbm-log-refresh-interval', this.autoRefreshInterval); this.startAutoRefresh(); // Restart timer with new interval if (this.autoRefreshInterval > 0) { this.showToast(`Logs will refresh every ${this.autoRefreshInterval} seconds`, 'info'); } else { this.showToast('Log auto-refresh is off', 'info'); } }); } // Scroll buttons const scrollToTopBtn = this.container.querySelector('#scroll-to-top-btn'); if (scrollToTopBtn) { scrollToTopBtn.addEventListener('click', () => this.scrollToTop()); } const scrollToBottomBtn = this.container.querySelector('#scroll-to-bottom-btn'); if (scrollToBottomBtn) { scrollToBottomBtn.addEventListener('click', () => this.scrollToBottom()); } // Scroll event listener for button visibility const logViewerContent = this.container.querySelector('.log-viewer-content'); if (logViewerContent) { logViewerContent.addEventListener('scroll', () => this.handleScroll()); } // Window resize event listener to reposition buttons window.addEventListener('resize', () => this.handleScroll()); } async loadRecentLogs() { try { const limit = this.currentLogLimit === 0 ? null : this.currentLogLimit; const response = await this.api.getLogs(limit, this.currentLogFile); this.logs = response.logs || []; this.applyFilters(); this.updateLastUpdatedStatus(); } catch (error) { this.updateLastUpdatedStatus(true); // Indicate error } } addLog(logData) { // Logs are now raw strings from the backend this.logs.unshift(logData); this.applyFilters(); } // New methods to be added show() { if (this.container) { show(this.container); this.scrollToBottom(); // Scroll to bottom when shown } } hide() { if (this.container) { hide(this.container); } } clearLogs() { this.logs = []; this.filteredLogs = []; this.renderLogs(); } log(level, message) { const timestamp = new Date().toISOString(); this.addLog(`${timestamp} [${level.toUpperCase()}] ${message}`); } applyFilters() { let filtered = [...this.logs]; // Search filter if (this.searchTerm) { filtered = filtered.filter(log => log.toLowerCase().includes(this.searchTerm) ); } this.filteredLogs = filtered; this.renderLogs(); } renderLogs() { const logViewerContent = this.container.querySelector('.log-viewer-content'); const logContainer = this.container.querySelector('#log-container'); if (this.filteredLogs.length === 0) { logContainer.innerHTML = `
📋
No logs match current filters
Try adjusting your filter settings
`; // If no logs, ensure the scrollable area is reset if (logViewerContent) { logViewerContent.scrollTop = 0; } return; } let html = ''; this.filteredLogs.forEach((log, index) => { // Logs are now raw strings, display them directly with clickable links html += `
${this.makeLinksClickable(log)}
`; }); logContainer.innerHTML = html; } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Converts URLs in text to clickable links while escaping the rest * @param {string} text - The text to process * @returns {string} - HTML with clickable links */ makeLinksClickable(text) { // URL regex patterns for both HTTP and HTTPS - handled identically const httpRegex = /(http:\/\/[^\s]+)/g; const httpsRegex = /(https:\/\/[^\s]+)/g; // Escape the entire text first for security const escapedText = this.escapeHtml(text); // Replace both HTTP and HTTPS URLs with identical clickable links let result = escapedText.replace(httpRegex, (url) => { return `${url}`; }); result = result.replace(httpsRegex, (url) => { return `${url}`; }); return result; } scrollToTop() { const logViewerContent = this.container.querySelector('.log-viewer-content'); if (logViewerContent) { logViewerContent.scrollTop = 0; } } scrollToBottom() { const logViewerContent = this.container.querySelector('.log-viewer-content'); if (logViewerContent) { logViewerContent.scrollTop = logViewerContent.scrollHeight; } } handleScroll() { const logViewerContent = this.container.querySelector('.log-viewer-content'); const scrollToTopBtn = this.container.querySelector('#scroll-to-top-btn'); const scrollToBottomBtn = this.container.querySelector('#scroll-to-bottom-btn'); const topButtonContainer = this.container.querySelector('.log-floating-scroll-top-btn'); const bottomButtonContainer = this.container.querySelector('.log-floating-scroll-bottom-btn'); if (!logViewerContent || !scrollToTopBtn || !scrollToBottomBtn || !topButtonContainer || !bottomButtonContainer) { return; } // Get the position of the log viewer content relative to the viewport const contentRect = logViewerContent.getBoundingClientRect(); const rightOffset = 40; // Space from right edge, accounting for scrollbar const verticalOffset = 8; // Small offset from top/bottom edges // Position the buttons relative to the log viewer content topButtonContainer.style.top = `${contentRect.top + verticalOffset}px`; topButtonContainer.style.right = `${rightOffset}px`; bottomButtonContainer.style.bottom = `${window.innerHeight - contentRect.bottom + verticalOffset}px`; bottomButtonContainer.style.right = `${rightOffset}px`; const { scrollTop, scrollHeight, clientHeight } = logViewerContent; const atTop = scrollTop === 0; // Add a small tolerance (1px) for bottom detection to handle rounding issues const atBottom = Math.abs(scrollTop + clientHeight - scrollHeight) <= 1; const isScrollable = scrollHeight > clientHeight; // Show/hide top button - visible when scrollable and not at top if (isScrollable && !atTop) { topButtonContainer.classList.add('visible'); } else { topButtonContainer.classList.remove('visible'); } // Show/hide bottom button - visible when scrollable and not at bottom if (isScrollable && !atBottom) { bottomButtonContainer.classList.add('visible'); } else { bottomButtonContainer.classList.remove('visible'); } } startAutoRefresh() { this.stopAutoRefresh(); // Clear any existing timer if (this.autoRefreshInterval > 0) { this.autoRefreshTimer = setInterval(() => { this.loadRecentLogs(); }, this.autoRefreshInterval * 1000); } } stopAutoRefresh() { if (this.autoRefreshTimer) { clearInterval(this.autoRefreshTimer); this.autoRefreshTimer = null; } } updateLastUpdatedStatus(isError = false) { const statusElement = this.container.querySelector('#log-last-updated-status'); if (isError) { statusElement.textContent = 'Last updated: Error loading logs'; statusElement.classList.add('error'); } else { statusElement.textContent = `Last updated: ${new Date().toLocaleTimeString()}`; statusElement.classList.remove('error'); } } showToast(message, type = 'info') { // If there's a global toast function available, use it if (window.qbitManageApp && window.qbitManageApp.showToast) { window.qbitManageApp.showToast(message, type); } } } export { LogViewer };