263 líneas
8.4 KiB
JavaScript
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)
|
|
})
|
|
})
|