Files
tail-monitor/index.js
2025-08-13 21:45:00 +02:00

263 líneas
8.4 KiB
JavaScript

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'
// 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) => {
// 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', async (req, res) => {
const endpoint = path.normalize(req.params.endpoint)
const file = path.join(directory, endpoint)
const isReconnect = req.query.reconnect === 'true'
// 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')
// 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'))
}
})
// For reconnections, start from current end, for new connections start from 'end'
const beginAt = isReconnect ? fs.statSync(file).size : 'end'
const stream = ts.createReadStream(file, {
beginAt: beginAt,
endOnError: true,
detectTruncate: true
})
// 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)
})
})