tail 100 lines

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2025-08-13 21:45:00 +02:00
padre 3a13763e02
commit ae4fb65e18
Se han modificado 3 ficheros con 1512 adiciones y 7 borrados

Ver fichero

@@ -11,6 +11,52 @@ const http = require('http'),
}), }),
directory = process.argv[2] || './logs' directory = process.argv[2] || './logs'
// Helper function to read last N lines from a file
function readLastLines(filePath, numLines = 100) {
return new Promise((resolve, reject) => {
try {
const stats = fs.statSync(filePath)
const fileSize = stats.size
if (fileSize === 0) {
resolve([])
return
}
const buffer = Buffer.alloc(Math.min(fileSize, 64 * 1024)) // Read max 64KB
const fd = fs.openSync(filePath, 'r')
// Calculate read position (start from end, go backwards)
const readSize = Math.min(fileSize, buffer.length)
const position = Math.max(0, fileSize - readSize)
fs.readSync(fd, buffer, 0, readSize, position)
fs.closeSync(fd)
// Convert to string and split by lines
const content = buffer.toString('utf8', 0, readSize)
let lines = content.split('\n')
// Remove last empty line if exists
if (lines[lines.length - 1] === '') {
lines.pop()
}
// If we read from the middle of the file, remove the first partial line
if (position > 0 && lines.length > 0) {
lines.shift()
}
// Return last N lines
const result = lines.slice(-numLines)
resolve(result)
} catch (error) {
reject(error)
}
})
}
// Middleware // Middleware
app.disable('x-powered-by') app.disable('x-powered-by')
app.use((req, res, next) => { app.use((req, res, next) => {
@@ -69,7 +115,7 @@ app.get('/tail/api/files', (req, res) => {
}) })
// Tail a specific file // Tail a specific file
app.get('/tail/api/files/:endpoint', (req, res) => { app.get('/tail/api/files/:endpoint', async (req, res) => {
const endpoint = path.normalize(req.params.endpoint) const endpoint = path.normalize(req.params.endpoint)
const file = path.join(directory, endpoint) const file = path.join(directory, endpoint)
const isReconnect = req.query.reconnect === 'true' const isReconnect = req.query.reconnect === 'true'
@@ -90,6 +136,37 @@ app.get('/tail/api/files/:endpoint', (req, res) => {
res.header('Cache-Control', 'no-cache') res.header('Cache-Control', 'no-cache')
res.header('X-Accel-Buffering', 'no') res.header('X-Accel-Buffering', 'no')
// Send initial message
const initialMessage = isReconnect
? `Reconnected to ${endpoint}...`
: `Tailing ${endpoint}...`
res.write(Buffer.from(`data: ${initialMessage}\n\n`))
// If it's not a reconnection, send the last 100 lines first
if (!isReconnect) {
try {
const lastLines = await readLastLines(file, 100)
if (lastLines.length > 0) {
// Send a separator message
res.write(Buffer.from(`data: ===== Last ${lastLines.length} lines =====\n\n`))
// Send each line with a marker that it's historical
lastLines.forEach(line => {
if (line.trim()) {
res.write(Buffer.from(`data: [HISTORY] ${line}\n\n`))
}
})
// Send separator for new content
res.write(Buffer.from(`data: ===== Live tail starts here =====\n\n`))
}
} catch (error) {
console.warn('Could not read file history:', error.message)
res.write(Buffer.from(`data: Could not load file history: ${error.message}\n\n`))
}
}
const transform = new Transform({ const transform = new Transform({
transform: (chunk, enc, cb) => { transform: (chunk, enc, cb) => {
cb(null, Buffer.from('data: ' + chunk.toString() + '\n\n')) cb(null, Buffer.from('data: ' + chunk.toString() + '\n\n'))
@@ -105,12 +182,6 @@ app.get('/tail/api/files/:endpoint', (req, res) => {
detectTruncate: true detectTruncate: true
}) })
// Send initial message
const initialMessage = isReconnect
? `Reconnected to ${endpoint}...`
: `Tailing ${endpoint}...`
res.write(Buffer.from(`data: ${initialMessage}\n\n`))
// Handle stream events // Handle stream events
stream.on('error', error => { stream.on('error', error => {
console.error('Stream error:', error) console.error('Stream error:', error)

764
public/main.css Archivo normal
Ver fichero

@@ -0,0 +1,764 @@
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Color palette */
--primary-color: #2563eb;
--primary-dark: #1d4ed8;
--secondary-color: #64748b;
--accent-color: #06b6d4;
--success-color: #10b981;
--warning-color: #f59e0b;
--danger-color: #ef4444;
/* Background colors */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--bg-dark: #0f172a;
--bg-darker: #020617;
/* Text colors */
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--text-inverse: #ffffff;
/* Border colors */
--border-light: #e2e8f0;
--border-medium: #cbd5e1;
--border-dark: #475569;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Fonts */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
/* Transitions */
--transition-fast: 150ms ease-in-out;
--transition-normal: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
}
/* Dark mode variables */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-muted: #94a3b8;
--border-light: #334155;
--border-medium: #475569;
--border-dark: #64748b;
}
}
body {
font-family: var(--font-sans);
background-color: var(--bg-secondary);
color: var(--text-primary);
line-height: 1.5;
overflow: hidden;
}
/* App container */
.app-container {
height: 100vh;
display: flex;
flex-direction: column;
}
/* Navigation */
.navbar {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
z-index: 50;
}
.nav-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-xl);
max-width: 100%;
}
.nav-brand {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.nav-brand i {
color: var(--primary-color);
font-size: 1.5rem;
}
.nav-brand h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.nav-brand a {
color: var(--text-primary);
text-decoration: none;
transition: color var(--transition-fast);
}
.nav-brand a:hover {
color: var(--primary-color);
}
.nav-info {
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.status-indicator {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-lg);
background: var(--bg-tertiary);
font-size: 0.875rem;
font-weight: 500;
border: 1px solid var(--border-light);
transition: all var(--transition-fast);
}
.status-indicator.connected {
background: rgba(16, 185, 129, 0.15);
color: var(--success-color);
border-color: rgba(16, 185, 129, 0.3);
}
.status-indicator.connected i {
color: var(--success-color);
animation: pulse-green 2s infinite;
}
.status-indicator.disconnected {
background: rgba(239, 68, 68, 0.15);
color: var(--danger-color);
border-color: rgba(239, 68, 68, 0.3);
}
.status-indicator.disconnected i {
color: var(--danger-color);
}
@keyframes pulse-green {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.current-file {
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
}
/* Main content */
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 300px;
background: var(--bg-primary);
border-right: 1px solid var(--border-light);
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-light);
}
.sidebar-header h3 {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.file-list {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
}
.file-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
user-select: none;
}
.file-item:hover {
background: var(--bg-tertiary);
transform: translateX(2px);
}
.file-item.active {
background: rgba(37, 99, 235, 0.1);
color: var(--primary-color);
font-weight: 500;
}
.file-item i {
color: var(--text-muted);
width: 1rem;
}
.file-item.active i {
color: var(--primary-color);
}
/* Log viewer */
.log-viewer {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Controls panel */
.controls-panel {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-light);
padding: var(--spacing-lg);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--spacing-lg);
}
.control-group {
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.control-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.control-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
/* Toggle switch */
.switch {
position: relative;
display: inline-block;
width: 48px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--border-medium);
transition: var(--transition-fast);
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: var(--transition-fast);
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(24px);
}
/* Input groups */
.input-group {
position: relative;
display: flex;
align-items: center;
}
.input-group input {
padding: var(--spacing-sm) var(--spacing-md);
padding-left: 2.5rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.875rem;
width: 200px;
transition: all var(--transition-fast);
}
.input-group input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.input-group i {
position: absolute;
left: var(--spacing-md);
color: var(--text-muted);
font-size: 0.875rem;
z-index: 1;
}
.btn-clear {
position: absolute;
right: var(--spacing-sm);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: var(--spacing-xs);
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.btn-clear:hover {
color: var(--danger-color);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
}
.btn-icon {
padding: var(--spacing-sm);
width: 2.5rem;
height: 2.5rem;
justify-content: center;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.btn-secondary:hover {
background: var(--border-light);
color: var(--text-primary);
transform: translateY(-1px);
}
/* Log container */
.log-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.log-header {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-light);
padding: var(--spacing-md) var(--spacing-lg);
}
.log-stats {
display: flex;
gap: var(--spacing-lg);
}
.stat {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: 0.875rem;
color: var(--text-secondary);
}
.stat i {
color: var(--text-muted);
}
/* Log content */
.log-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
background: var(--bg-darker);
color: var(--text-inverse);
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.6;
}
/* Welcome message */
.welcome-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: var(--text-secondary);
}
.welcome-icon {
font-size: 4rem;
color: var(--text-muted);
margin-bottom: var(--spacing-lg);
}
.welcome-message h3 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: var(--spacing-md);
color: var(--text-primary);
}
.welcome-message p {
font-size: 1rem;
margin-bottom: var(--spacing-xl);
max-width: 400px;
}
.features {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.feature {
display: flex;
align-items: center;
gap: var(--spacing-md);
font-size: 0.875rem;
}
.feature i {
color: var(--primary-color);
width: 1.25rem;
}
/* Log lines */
.log-line {
display: block;
padding: var(--spacing-xs) var(--spacing-sm);
margin-bottom: var(--spacing-xs);
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
cursor: pointer;
white-space: pre-wrap;
word-break: break-all;
background: rgba(255, 255, 255, 0.02);
border-left: 3px solid transparent;
}
.log-line:hover {
background: rgba(255, 255, 255, 0.05);
}
.log-line.expanded {
background: rgba(37, 99, 235, 0.1);
border-left-color: var(--primary-color);
}
.log-line.highlighted {
animation: pulse 1s infinite alternate;
background: rgba(245, 158, 11, 0.2);
border-left-color: var(--warning-color);
}
.log-line.hidden {
display: none;
}
/* Log line types */
.log-line.success {
border-left-color: var(--success-color);
background: rgba(16, 185, 129, 0.1);
}
.log-line.error {
border-left-color: var(--danger-color);
background: rgba(239, 68, 68, 0.1);
}
.log-line.warning {
border-left-color: var(--warning-color);
background: rgba(245, 158, 11, 0.1);
}
.log-line.info {
border-left-color: var(--accent-color);
background: rgba(6, 182, 212, 0.1);
}
.log-line.reconnect-message {
border-left-color: var(--warning-color);
background: rgba(245, 158, 11, 0.15);
color: var(--warning-color);
font-style: italic;
text-align: center;
margin: var(--spacing-sm) 0;
}
.log-line.reconnect-message i {
margin-right: var(--spacing-xs);
}
.log-line.reconnect-success {
border-left-color: var(--success-color);
background: rgba(16, 185, 129, 0.15);
color: var(--success-color);
font-style: italic;
text-align: center;
margin: var(--spacing-sm) 0;
}
.log-line.reconnect-success i {
margin-right: var(--spacing-xs);
}
.log-line.historical {
opacity: 0.7;
border-left-color: var(--text-muted);
background: rgba(148, 163, 184, 0.05);
}
.log-line.separator-message {
border-left-color: var(--accent-color);
background: rgba(6, 182, 212, 0.1);
color: var(--accent-color);
font-weight: 600;
text-align: center;
margin: var(--spacing-md) 0;
font-style: normal;
}
.log-line.separator-message.history-separator {
border-left-color: var(--warning-color);
background: rgba(245, 158, 11, 0.1);
color: var(--warning-color);
}
.log-line.separator-message.live-separator {
border-left-color: var(--success-color);
background: rgba(16, 185, 129, 0.1);
color: var(--success-color);
}
.log-line.separator-message i {
margin: 0 var(--spacing-sm);
}
/* Loading states */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
padding: var(--spacing-xl);
color: var(--text-muted);
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
visibility: hidden;
transition: all var(--transition-normal);
}
.loading-overlay.visible {
opacity: 1;
visibility: visible;
}
.loading-spinner {
background: var(--bg-primary);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-lg);
}
.loading-spinner i {
font-size: 2rem;
color: var(--primary-color);
}
/* Animations */
@keyframes pulse {
from { opacity: 0.8; }
to { opacity: 1; }
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.log-line {
animation: fadeIn 0.3s ease-out;
}
/* Scrollbar styling */
.log-content::-webkit-scrollbar,
.file-list::-webkit-scrollbar {
width: 8px;
}
.log-content::-webkit-scrollbar-track,
.file-list::-webkit-scrollbar-track {
background: transparent;
}
.log-content::-webkit-scrollbar-thumb,
.file-list::-webkit-scrollbar-thumb {
background: var(--border-medium);
border-radius: 4px;
}
.log-content::-webkit-scrollbar-thumb:hover,
.file-list::-webkit-scrollbar-thumb:hover {
background: var(--border-dark);
}
/* Responsive design */
@media (max-width: 768px) {
.sidebar {
width: 250px;
}
.nav-content {
padding: var(--spacing-md);
}
.controls-panel {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-md);
}
.control-group {
flex-wrap: wrap;
justify-content: space-between;
}
.input-group input {
width: 150px;
}
}
@media (max-width: 480px) {
.main-content {
flex-direction: column;
}
.sidebar {
width: 100%;
height: 200px;
border-right: none;
border-bottom: 1px solid var(--border-light);
}
.nav-info {
flex-direction: column;
gap: var(--spacing-sm);
}
}

670
public/main.js Archivo normal
Ver fichero

@@ -0,0 +1,670 @@
/**
* 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, 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();
// Check if it's a separator or special message
if (data.includes('===== Last') && data.includes('lines =====')) {
this.addSeparatorMessage(data, 'history-separator');
return;
}
if (data.includes('===== Live tail starts here =====')) {
this.addSeparatorMessage(data, 'live-separator');
return;
}
// Check if it's a historical line
const isHistorical = data.startsWith('[HISTORY] ');
const processedData = isHistorical ?
this.processLogData(data.substring(10)) : // Remove [HISTORY] prefix
this.processLogData(data);
// Create log line element
const logLine = this.createLogLine(processedData, isHistorical);
// 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, isHistorical = false) {
const logLine = document.createElement('div');
logLine.className = 'log-line';
if (isHistorical) {
logLine.classList.add('historical');
}
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');
}
/**
* Add a separator message to the log
*/
addSeparatorMessage(text, type) {
const separatorLine = document.createElement('div');
separatorLine.className = `log-line separator-message ${type}`;
separatorLine.innerHTML = `<i class="fas fa-minus"></i> ${text} <i class="fas fa-minus"></i>`;
this.elements.logOutput.appendChild(separatorLine);
if (this.autoScroll) {
this.scrollToBottom();
}
}
/**
* Add a reconnection message to the log
*/
addReconnectionMessage() {
const reconnectLine = document.createElement('div');
reconnectLine.className = 'log-line reconnect-message';
reconnectLine.innerHTML = '<i class="fas fa-plug"></i> 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 = '<i class="fas fa-check-circle"></i> 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);
}
}
});