595 líneas
19 KiB
JavaScript
595 líneas
19 KiB
JavaScript
/**
|
|
* 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 = '<div class="loading"><i class="fas fa-spinner fa-spin"></i><span>Loading files...</span></div>';
|
|
|
|
// 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 = `
|
|
<div class="loading">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
<span>Failed to load files: ${error.message}</span>
|
|
</div>
|
|
`;
|
|
// 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 = `
|
|
<div class="loading">
|
|
<i class="fas fa-folder-open"></i>
|
|
<span>No files found</span>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
this.elements.fileList.innerHTML = '';
|
|
|
|
files.forEach(file => {
|
|
const fileItem = document.createElement('div');
|
|
fileItem.className = 'file-item';
|
|
fileItem.innerHTML = `
|
|
<i class="fas fa-file-alt"></i>
|
|
<span>${this.escapeHtml(file.name)}</span>
|
|
`;
|
|
|
|
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) {
|
|
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
|
|
this.clearLogs();
|
|
|
|
try {
|
|
// Start SSE connection
|
|
this.eventSource = new EventSource(`api/files/${encodeURIComponent(filename)}`);
|
|
|
|
this.eventSource.onopen = () => {
|
|
this.hideLoading();
|
|
this.setConnectionStatus(true);
|
|
console.log('SSE connection established');
|
|
};
|
|
|
|
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
|
|
setTimeout(() => {
|
|
if (this.currentFile && this.eventSource.readyState === EventSource.CLOSED) {
|
|
console.log('Attempting to reconnect...');
|
|
this.startTailing(this.currentFile);
|
|
}
|
|
}, 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 = '<i class="fas fa-circle"></i><span>Connected</span>';
|
|
} else {
|
|
status.className = 'status-indicator disconnected';
|
|
status.innerHTML = '<i class="fas fa-circle"></i><span>Disconnected</span>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = `<i class="fas fa-clock"></i><span>Last update: ${timeStr}</span>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
|
|
/**
|
|
* 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', () => {
|
|
new LogTailMonitor();
|
|
});
|
|
|
|
// Handle page visibility changes
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') {
|
|
// Refresh connection when page becomes visible
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 1000);
|
|
}
|
|
});
|