diff --git a/index.js b/index.js index 0bc77f8..7ec91e7 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,52 @@ const http = require('http'), }), 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 app.disable('x-powered-by') app.use((req, res, next) => { @@ -69,7 +115,7 @@ app.get('/tail/api/files', (req, res) => { }) // 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 file = path.join(directory, endpoint) 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('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({ transform: (chunk, enc, cb) => { cb(null, Buffer.from('data: ' + chunk.toString() + '\n\n')) @@ -105,12 +182,6 @@ app.get('/tail/api/files/:endpoint', (req, res) => { detectTruncate: true }) - // Send initial message - const initialMessage = isReconnect - ? `Reconnected to ${endpoint}...` - : `Tailing ${endpoint}...` - res.write(Buffer.from(`data: ${initialMessage}\n\n`)) - // Handle stream events stream.on('error', error => { console.error('Stream error:', error) diff --git a/public/main.css b/public/main.css new file mode 100644 index 0000000..0fa101e --- /dev/null +++ b/public/main.css @@ -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); + } +} diff --git a/public/main.js b/public/main.js new file mode 100644 index 0000000..72353ef --- /dev/null +++ b/public/main.js @@ -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 = '