initial commit
Este commit está contenido en:
86
.gitignore
vendido
Archivo normal
86
.gitignore
vendido
Archivo normal
@@ -0,0 +1,86 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
*.lock
|
||||
*-lock.json
|
||||
111
README.md
Archivo normal
111
README.md
Archivo normal
@@ -0,0 +1,111 @@
|
||||
# 🚀 Log Tail Monitor
|
||||
|
||||
A modern, beautiful real-time log monitoring application with a web interface. Monitor your log files in real-time using Server-Sent Events (SSE) with advanced filtering, highlighting, and export capabilities.
|
||||
|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🔄 Real-time Monitoring**: Uses Server-Sent Events for instant log updates
|
||||
- **🎨 Modern UI**: Beautiful, responsive interface with dark/light mode support
|
||||
- **🔍 Advanced Filtering**: Filter logs by content with real-time search
|
||||
- **✨ Smart Highlighting**: Highlight important text with visual indicators
|
||||
- **📊 Log Classification**: Automatic classification of log levels (success, error, warning, info)
|
||||
- **💾 Export Functionality**: Export filtered logs to text files
|
||||
- **⏸️ Pause/Resume**: Control log streaming as needed
|
||||
- **📱 Mobile Responsive**: Works great on all device sizes
|
||||
- **🔒 Secure**: Path traversal protection and proper error handling
|
||||
- **⚡ High Performance**: Handles thousands of log lines efficiently
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 14.0.0 or higher
|
||||
- npm (comes with Node.js)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone or download this repository
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
|
||||
#### Production Mode
|
||||
```bash
|
||||
npm start [directory]
|
||||
```
|
||||
|
||||
#### Development Mode (default ./logs directory)
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
#### Demo Mode (with sample logs)
|
||||
```bash
|
||||
npm run demo
|
||||
```
|
||||
|
||||
### Example
|
||||
```bash
|
||||
# Monitor logs in /var/log directory
|
||||
npm start /var/log
|
||||
|
||||
# Monitor logs in current directory's logs folder
|
||||
npm run dev
|
||||
|
||||
# Run demo with sample data
|
||||
npm run demo
|
||||
```
|
||||
|
||||
## 🌐 Usage
|
||||
|
||||
1. **Access the Web Interface**: Open your browser and go to `http://localhost:3000/tail`
|
||||
|
||||
2. **Select a File**: Choose a log file from the sidebar to start monitoring
|
||||
|
||||
3. **Filter Logs**: Use the filter input to show only logs containing specific text
|
||||
|
||||
4. **Highlight Text**: Use the highlight input to emphasize important content
|
||||
|
||||
5. **Control Playback**:
|
||||
- Toggle auto-scroll on/off
|
||||
- Pause/resume log streaming
|
||||
- Clear current logs
|
||||
- Export filtered results
|
||||
|
||||
6. **Interact with Logs**: Click on any log line to expand it for better readability
|
||||
|
||||
## 🔧 API Endpoints
|
||||
|
||||
### REST API
|
||||
|
||||
- `GET /tail/api/health` - Health check and status
|
||||
- `GET /tail/api/files` - List available log files
|
||||
- `GET /tail/api/files/:filename` - Stream specific file (SSE)
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Command Line Arguments
|
||||
|
||||
```bash
|
||||
node index.js [log-directory]
|
||||
```
|
||||
|
||||
- `log-directory` - Path to directory containing log files (default: ./logs)
|
||||
|
||||
## 🎨 Log Classification
|
||||
|
||||
The application automatically classifies log lines:
|
||||
|
||||
- **🟢 Success**: HTTP 2xx responses, successful operations
|
||||
- **🔴 Error**: HTTP non-2xx responses, errors, failures
|
||||
- **🟡 Warning**: Warning messages
|
||||
- **🔵 Info**: General information, bot traffic
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
184
index.js
Archivo normal
184
index.js
Archivo normal
@@ -0,0 +1,184 @@
|
||||
const http = require('http'),
|
||||
express = require('express'),
|
||||
app = express(),
|
||||
path = require('path'),
|
||||
fs = require('fs'),
|
||||
ts = require('tail-stream'),
|
||||
Transform = require('stream').Transform,
|
||||
server = http.createServer(app).listen(3000, () => {
|
||||
console.log(`🚀 Log Tail Monitor running on: http://${server.address().address}:${server.address().port}/tail`)
|
||||
console.log(`📁 Monitoring directory: ${directory}`)
|
||||
}),
|
||||
directory = process.argv[2] || './logs'
|
||||
|
||||
// Middleware
|
||||
app.disable('x-powered-by')
|
||||
app.use((req, res, next) => {
|
||||
// CORS headers
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept')
|
||||
next()
|
||||
})
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/tail/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
directory: directory,
|
||||
directoryExists: fs.existsSync(directory)
|
||||
})
|
||||
})
|
||||
|
||||
// Get list of files
|
||||
app.get('/tail/api/files', (req, res) => {
|
||||
try {
|
||||
if (fs.existsSync(directory)) {
|
||||
const files = fs.readdirSync(directory, { withFileTypes: true })
|
||||
.filter(file => file.isFile())
|
||||
.map(file => ({
|
||||
name: file.name,
|
||||
size: fs.statSync(path.join(directory, file.name)).size,
|
||||
modified: fs.statSync(path.join(directory, file.name)).mtime
|
||||
}))
|
||||
.sort((a, b) => b.modified - a.modified) // Sort by most recently modified
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
files: files,
|
||||
directory: directory,
|
||||
count: files.length
|
||||
})
|
||||
} else {
|
||||
res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Directory not found',
|
||||
directory: directory,
|
||||
files: []
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading directory:', error)
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: 'Internal server error',
|
||||
files: []
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Tail a specific file
|
||||
app.get('/tail/api/files/:endpoint', (req, res) => {
|
||||
const endpoint = path.normalize(req.params.endpoint)
|
||||
const file = path.join(directory, endpoint)
|
||||
|
||||
// Security check - ensure file is within the directory
|
||||
if (!file.startsWith(path.resolve(directory))) {
|
||||
return res.status(403).end('Access denied')
|
||||
}
|
||||
|
||||
if (!fs.existsSync(file)) {
|
||||
return res.status(404).end('File not found')
|
||||
}
|
||||
|
||||
try {
|
||||
// Set SSE headers
|
||||
res.header('Content-Type', 'text/event-stream')
|
||||
res.header('Connection', 'keep-alive')
|
||||
res.header('Cache-Control', 'no-cache')
|
||||
res.header('X-Accel-Buffering', 'no')
|
||||
|
||||
const transform = new Transform({
|
||||
transform: (chunk, enc, cb) => {
|
||||
cb(null, Buffer.from('data: ' + chunk.toString() + '\n\n'))
|
||||
}
|
||||
})
|
||||
|
||||
const stream = ts.createReadStream(file, {
|
||||
beginAt: 'end',
|
||||
endOnError: true,
|
||||
detectTruncate: true
|
||||
})
|
||||
|
||||
// Send initial message
|
||||
res.write(Buffer.from(`data: Tailing ${endpoint}...\n\n`))
|
||||
|
||||
// Handle stream events
|
||||
stream.on('error', error => {
|
||||
console.error('Stream error:', error)
|
||||
transform.destroy(error)
|
||||
res.end(Buffer.from(`data: Error: ${error.message}\n\n`))
|
||||
})
|
||||
|
||||
stream.on('truncate', () => {
|
||||
res.write(Buffer.from('data: File was truncated, continuing from beginning...\n\n'))
|
||||
})
|
||||
|
||||
// Pipe stream to response
|
||||
stream.pipe(transform).pipe(res)
|
||||
|
||||
// Keep connection alive with periodic pings
|
||||
const pingInterval = setInterval(() => {
|
||||
if (!res.writableEnded) {
|
||||
res.write(Buffer.from('data: \n\n')) // Empty ping
|
||||
} else {
|
||||
clearInterval(pingInterval)
|
||||
}
|
||||
}, 30000) // 30 second ping
|
||||
|
||||
// Clean up on client disconnect
|
||||
req.on('close', () => {
|
||||
clearInterval(pingInterval)
|
||||
stream.destroy()
|
||||
transform.destroy()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error setting up tail stream:', error)
|
||||
res.status(500).end('Internal server error')
|
||||
}
|
||||
})
|
||||
|
||||
// Serve static files
|
||||
app.use('/tail', express.static(path.join(__dirname, 'public')))
|
||||
|
||||
// Root redirect
|
||||
app.get('/', (req, res) => {
|
||||
res.redirect('/tail')
|
||||
})
|
||||
|
||||
// Handle 404s
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Endpoint not found',
|
||||
path: req.path
|
||||
})
|
||||
})
|
||||
|
||||
// Global error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err)
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: 'Internal server error'
|
||||
})
|
||||
})
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Received SIGTERM, shutting down gracefully...')
|
||||
server.close(() => {
|
||||
console.log('Server closed')
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Received SIGINT, shutting down gracefully...')
|
||||
server.close(() => {
|
||||
console.log('Server closed')
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
32
package.json
Archivo normal
32
package.json
Archivo normal
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "tail-monitor",
|
||||
"version": "2.0.0",
|
||||
"description": "Modern real-time log tail monitoring with beautiful web interface",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node index.js ./logs",
|
||||
"demo": "mkdir -p ./demo-logs && node index.js ./demo-logs",
|
||||
"test-logs": "mkdir -p ./demo-logs && echo 'Starting demo log generation...' && node -e 'setInterval(() => console.log(new Date().toISOString() + \" [INFO] Sample log entry \" + Math.random()), 1000)' > ./demo-logs/sample.log 2>&1 &",
|
||||
"clean": "rm -rf ./demo-logs"
|
||||
},
|
||||
"keywords": [
|
||||
"log",
|
||||
"tail",
|
||||
"monitoring",
|
||||
"real-time",
|
||||
"web",
|
||||
"sse",
|
||||
"server-sent-events"
|
||||
],
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"tail-stream": "^0.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"author": "ale",
|
||||
"license": "MIT"
|
||||
}
|
||||
167
public/index.html
Archivo normal
167
public/index.html
Archivo normal
@@ -0,0 +1,167 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Log Tail Monitor</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Real-time log file monitoring with Server-Sent Events">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Icons -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" type="text/css" href="main.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Navigation Bar -->
|
||||
<nav class="navbar">
|
||||
<div class="nav-content">
|
||||
<div class="nav-brand">
|
||||
<i class="fas fa-terminal"></i>
|
||||
<h1><a href="/tail">Log Tail Monitor</a></h1>
|
||||
</div>
|
||||
<div class="nav-info">
|
||||
<span id="connection-status" class="status-indicator">
|
||||
<i class="fas fa-circle"></i>
|
||||
<span>Disconnected</span>
|
||||
</span>
|
||||
<span id="current-file" class="current-file"></span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Sidebar for file selection -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3><i class="fas fa-folder-open"></i> Available Files</h3>
|
||||
<button id="refresh-files" class="btn btn-icon" title="Refresh file list">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-list" id="file-list">
|
||||
<div class="loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>Loading files...</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Log viewer -->
|
||||
<section class="log-viewer">
|
||||
<!-- Controls -->
|
||||
<div class="controls-panel">
|
||||
<div class="control-group">
|
||||
<div class="control-item">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="auto-scroll" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="control-label">Auto Scroll</span>
|
||||
</div>
|
||||
|
||||
<div class="control-item">
|
||||
<div class="input-group">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="filter-input" placeholder="Filter logs..." autocomplete="off">
|
||||
<button id="clear-filter" class="btn-clear" title="Clear filter">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-item">
|
||||
<div class="input-group">
|
||||
<i class="fas fa-highlighter"></i>
|
||||
<input type="text" id="highlight-input" placeholder="Highlight text..." autocomplete="off">
|
||||
<button id="clear-highlight" class="btn-clear" title="Clear highlight">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<button id="pause-btn" class="btn btn-secondary">
|
||||
<i class="fas fa-pause"></i>
|
||||
<span>Pause</span>
|
||||
</button>
|
||||
<button id="clear-logs" class="btn btn-secondary">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
<button id="export-logs" class="btn btn-secondary">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log content -->
|
||||
<div class="log-container">
|
||||
<div class="log-header">
|
||||
<div class="log-stats">
|
||||
<span class="stat">
|
||||
<i class="fas fa-list"></i>
|
||||
<span id="line-count">0</span> lines
|
||||
</span>
|
||||
<span class="stat">
|
||||
<i class="fas fa-eye"></i>
|
||||
<span id="visible-count">0</span> visible
|
||||
</span>
|
||||
<span class="stat" id="last-update-time">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>Never updated</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-content" id="log-output">
|
||||
<div class="welcome-message" id="welcome-message">
|
||||
<div class="welcome-icon">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</div>
|
||||
<h3>Welcome to Log Tail Monitor</h3>
|
||||
<p>Select a file from the sidebar to start monitoring logs in real-time.</p>
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<i class="fas fa-filter"></i>
|
||||
<span>Filter logs by content</span>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<i class="fas fa-highlighter"></i>
|
||||
<span>Highlight important text</span>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>Export filtered results</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div class="loading-overlay" id="loading-overlay">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
706
public/main.css
Archivo normal
706
public/main.css
Archivo normal
@@ -0,0 +1,706 @@
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
594
public/main.js
Archivo normal
594
public/main.js
Archivo normal
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
Referencia en una nueva incidencia
Block a user