Initial commit: LogMaster with Viewer Collector Monitor modes
This commit is contained in:
497
dist-server/collector.js
Normal file
497
dist-server/collector.js
Normal file
@@ -0,0 +1,497 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.LogCollector = void 0;
|
||||
const dgram = __importStar(require("dgram"));
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
const zlib = __importStar(require("zlib"));
|
||||
class LogCollector {
|
||||
config;
|
||||
streams = new Map();
|
||||
buffers = new Map();
|
||||
fileSizes = new Map();
|
||||
fileCreatedAt = new Map();
|
||||
rotationTimer = null;
|
||||
udpServer = null;
|
||||
forwardBuffers = new Map();
|
||||
forwardTimers = new Map();
|
||||
constructor(config = {}) {
|
||||
this.config = {
|
||||
storageDir: config.storageDir || path.join(process.cwd(), 'collector-data'),
|
||||
maxLinesPerStream: config.maxLinesPerStream || 100000,
|
||||
maxStreams: config.maxStreams || 50,
|
||||
udpPort: config.udpPort || 5140,
|
||||
udpEnabled: config.udpEnabled !== false,
|
||||
rotation: config.rotation || {
|
||||
maxSizeBytes: 5 * 1024 * 1024, // 5MB default
|
||||
maxAgeMs: 10 * 60 * 1000, // 10 min default
|
||||
compression: 'gzip',
|
||||
fileExtension: '.log',
|
||||
},
|
||||
forwardTargets: config.forwardTargets || [],
|
||||
persistToDisk: config.persistToDisk !== false,
|
||||
};
|
||||
if (!fs.existsSync(this.config.storageDir)) {
|
||||
fs.mkdirSync(this.config.storageDir, { recursive: true });
|
||||
}
|
||||
this.loadExistingStreams();
|
||||
// Start rotation check timer
|
||||
if (this.config.rotation.maxAgeMs > 0) {
|
||||
this.rotationTimer = setInterval(() => this.checkTimeRotation(), Math.min(this.config.rotation.maxAgeMs / 2, 30000));
|
||||
}
|
||||
// Start UDP server
|
||||
if (this.config.udpEnabled) {
|
||||
this.startUdpServer();
|
||||
}
|
||||
}
|
||||
startUdpServer() {
|
||||
try {
|
||||
this.udpServer = dgram.createSocket('udp4');
|
||||
this.udpServer.on('message', (msg, rinfo) => {
|
||||
const message = msg.toString('utf-8').trim();
|
||||
if (!message)
|
||||
return;
|
||||
// Parse stream ID from message or use sender IP as stream
|
||||
// Format: <streamId>|<message> or just <message> (uses IP:port as stream)
|
||||
let streamId;
|
||||
let logLine;
|
||||
const pipeIdx = message.indexOf('|');
|
||||
if (pipeIdx > 0 && pipeIdx < 64 && !message.substring(0, pipeIdx).includes(' ')) {
|
||||
streamId = message.substring(0, pipeIdx);
|
||||
logLine = message.substring(pipeIdx + 1);
|
||||
}
|
||||
else {
|
||||
streamId = `udp-${rinfo.address}`;
|
||||
logLine = message;
|
||||
}
|
||||
this.ingest(streamId, [logLine], `UDP ${rinfo.address}:${rinfo.port}`);
|
||||
});
|
||||
this.udpServer.on('error', (err) => {
|
||||
console.error(`UDP server error: ${err.message}`);
|
||||
});
|
||||
this.udpServer.bind(this.config.udpPort, '0.0.0.0', () => {
|
||||
console.log(` UDP collector listening on port ${this.config.udpPort}`);
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to start UDP server:', err.message);
|
||||
}
|
||||
}
|
||||
loadExistingStreams() {
|
||||
try {
|
||||
const metaFile = path.join(this.config.storageDir, '_streams.json');
|
||||
if (fs.existsSync(metaFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(metaFile, 'utf-8'));
|
||||
for (const stream of data) {
|
||||
this.streams.set(stream.id, stream);
|
||||
const streamFile = this.getStreamFilePath(stream.id);
|
||||
if (fs.existsSync(streamFile)) {
|
||||
const content = fs.readFileSync(streamFile, 'utf-8');
|
||||
const lines = content.split('\n').filter(l => l.length > 0);
|
||||
this.buffers.set(stream.id, lines);
|
||||
const stat = fs.statSync(streamFile);
|
||||
this.fileSizes.set(stream.id, stat.size);
|
||||
this.fileCreatedAt.set(stream.id, stat.birthtimeMs || Date.now());
|
||||
}
|
||||
else {
|
||||
this.buffers.set(stream.id, []);
|
||||
this.fileSizes.set(stream.id, 0);
|
||||
this.fileCreatedAt.set(stream.id, Date.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Error loading collector streams:', err);
|
||||
}
|
||||
}
|
||||
saveMetadata() {
|
||||
const metaFile = path.join(this.config.storageDir, '_streams.json');
|
||||
fs.writeFileSync(metaFile, JSON.stringify(Array.from(this.streams.values()), null, 2));
|
||||
}
|
||||
getStreamFilePath(streamId) {
|
||||
return path.join(this.config.storageDir, `${streamId}${this.config.rotation.fileExtension}`);
|
||||
}
|
||||
getRotatedFilePath(streamId) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const ext = this.config.rotation.fileExtension;
|
||||
const compExt = this.getCompressionExtension();
|
||||
return path.join(this.config.storageDir, `${streamId}_${ts}${ext}${compExt}`);
|
||||
}
|
||||
getCompressionExtension() {
|
||||
switch (this.config.rotation.compression) {
|
||||
case 'gzip': return '.gz';
|
||||
case 'bzip2': return '.bz2';
|
||||
case 'xz': return '.xz';
|
||||
case 'zstd': return '.zst';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
saveStream(streamId) {
|
||||
if (!this.config.persistToDisk)
|
||||
return;
|
||||
const lines = this.buffers.get(streamId) || [];
|
||||
const streamFile = this.getStreamFilePath(streamId);
|
||||
const content = lines.join('\n') + (lines.length > 0 ? '\n' : '');
|
||||
fs.writeFileSync(streamFile, content);
|
||||
this.fileSizes.set(streamId, Buffer.byteLength(content, 'utf-8'));
|
||||
}
|
||||
checkSizeRotation(streamId) {
|
||||
if (this.config.rotation.maxSizeBytes <= 0)
|
||||
return;
|
||||
const size = this.fileSizes.get(streamId) || 0;
|
||||
if (size >= this.config.rotation.maxSizeBytes) {
|
||||
this.rotateStream(streamId);
|
||||
}
|
||||
}
|
||||
checkTimeRotation() {
|
||||
if (this.config.rotation.maxAgeMs <= 0)
|
||||
return;
|
||||
const now = Date.now();
|
||||
for (const [streamId, createdAt] of this.fileCreatedAt) {
|
||||
if (now - createdAt >= this.config.rotation.maxAgeMs) {
|
||||
const buffer = this.buffers.get(streamId);
|
||||
if (buffer && buffer.length > 0) {
|
||||
this.rotateStream(streamId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rotateStream(streamId) {
|
||||
const srcFile = this.getStreamFilePath(streamId);
|
||||
if (!fs.existsSync(srcFile))
|
||||
return;
|
||||
const content = fs.readFileSync(srcFile);
|
||||
if (content.length === 0)
|
||||
return;
|
||||
const destFile = this.getRotatedFilePath(streamId);
|
||||
// Compress and write
|
||||
let compressed;
|
||||
switch (this.config.rotation.compression) {
|
||||
case 'gzip':
|
||||
compressed = zlib.gzipSync(content);
|
||||
break;
|
||||
case 'bzip2':
|
||||
case 'xz':
|
||||
case 'zstd':
|
||||
// For these, just copy without compression (would need external tools)
|
||||
compressed = content;
|
||||
break;
|
||||
default:
|
||||
compressed = content;
|
||||
}
|
||||
fs.writeFileSync(destFile, compressed);
|
||||
console.log(` Rotated: ${streamId} -> ${path.basename(destFile)} (${compressed.length} bytes)`);
|
||||
// Reset current file
|
||||
fs.writeFileSync(srcFile, '');
|
||||
this.buffers.set(streamId, []);
|
||||
this.fileSizes.set(streamId, 0);
|
||||
this.fileCreatedAt.set(streamId, Date.now());
|
||||
const stream = this.streams.get(streamId);
|
||||
if (stream) {
|
||||
stream.lineCount = 0;
|
||||
stream.currentFileSize = 0;
|
||||
}
|
||||
this.saveMetadata();
|
||||
}
|
||||
// ---- Forwarding ----
|
||||
forwardLines(streamId, lines) {
|
||||
for (const target of this.config.forwardTargets) {
|
||||
const key = `${streamId}:${target.type}:${target.url || target.path || target.host}`;
|
||||
if (!this.forwardBuffers.has(key))
|
||||
this.forwardBuffers.set(key, []);
|
||||
const buf = this.forwardBuffers.get(key);
|
||||
buf.push(...lines);
|
||||
const batchSize = target.batchSize || 100;
|
||||
if (buf.length >= batchSize) {
|
||||
this.flushForward(key, target, streamId);
|
||||
}
|
||||
else if (!this.forwardTimers.has(key)) {
|
||||
const flushMs = target.flushIntervalMs || 5000;
|
||||
this.forwardTimers.set(key, setTimeout(() => {
|
||||
this.flushForward(key, target, streamId);
|
||||
this.forwardTimers.delete(key);
|
||||
}, flushMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
flushForward(key, target, streamId) {
|
||||
const lines = this.forwardBuffers.get(key) || [];
|
||||
if (lines.length === 0)
|
||||
return;
|
||||
this.forwardBuffers.set(key, []);
|
||||
switch (target.type) {
|
||||
case 'http':
|
||||
this.forwardHttp(target, streamId, lines);
|
||||
break;
|
||||
case 'file':
|
||||
this.forwardFile(target, lines);
|
||||
break;
|
||||
case 'udp':
|
||||
this.forwardUdp(target, streamId, lines);
|
||||
break;
|
||||
case 'stream':
|
||||
this.forwardToStream(target, streamId, lines);
|
||||
break;
|
||||
case 'console':
|
||||
for (const l of lines)
|
||||
console.log(`[FWD:${streamId}] ${l}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
forwardHttp(target, streamId, lines) {
|
||||
if (!target.url)
|
||||
return;
|
||||
const payload = target.format === 'json'
|
||||
? JSON.stringify({ stream: streamId, lines, timestamp: new Date().toISOString() })
|
||||
: lines.join('\n');
|
||||
try {
|
||||
const url = new URL(target.url);
|
||||
const transport = url.protocol === 'https:' ? require('https') : require('http');
|
||||
const req = transport.request({
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': target.format === 'json' ? 'application/json' : 'text/plain', 'X-Stream-Id': streamId },
|
||||
});
|
||||
req.on('error', (e) => console.error(`Forward HTTP error: ${e.message}`));
|
||||
req.write(payload);
|
||||
req.end();
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Forward HTTP error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
forwardFile(target, lines) {
|
||||
if (!target.path)
|
||||
return;
|
||||
try {
|
||||
fs.appendFileSync(target.path, lines.join('\n') + '\n');
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Forward file error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
forwardUdp(target, streamId, lines) {
|
||||
if (!target.host || !target.port)
|
||||
return;
|
||||
try {
|
||||
const client = dgram.createSocket('udp4');
|
||||
for (const line of lines) {
|
||||
const msg = Buffer.from(`${streamId}|${line}`);
|
||||
client.send(msg, target.port, target.host);
|
||||
}
|
||||
setTimeout(() => client.close(), 1000);
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Forward UDP error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
forwardToStream(target, sourceStreamId, lines) {
|
||||
const targetId = target.targetStream;
|
||||
if (!targetId || targetId === sourceStreamId)
|
||||
return; // Prevent infinite loop
|
||||
try {
|
||||
// Prefix lines with source stream info
|
||||
const prefixed = lines.map(l => `[fwd:${sourceStreamId}] ${l}`);
|
||||
// Ingest directly into the target stream without triggering forwards again
|
||||
const stream = this.getOrCreateStream(targetId, `Forwarded from ${sourceStreamId}`);
|
||||
const buffer = this.buffers.get(targetId);
|
||||
for (const line of prefixed) {
|
||||
if (buffer.length >= this.config.maxLinesPerStream)
|
||||
buffer.shift();
|
||||
buffer.push(line);
|
||||
}
|
||||
stream.lineCount = buffer.length;
|
||||
stream.lastReceived = new Date().toISOString();
|
||||
if (this.config.persistToDisk)
|
||||
this.saveStream(targetId);
|
||||
this.saveMetadata();
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Forward stream error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
// ---- Public API ----
|
||||
getOrCreateStream(streamId, name) {
|
||||
if (this.streams.has(streamId))
|
||||
return this.streams.get(streamId);
|
||||
if (this.streams.size >= this.config.maxStreams)
|
||||
throw new Error(`Max streams (${this.config.maxStreams}) reached`);
|
||||
const stream = {
|
||||
id: streamId, name: name || streamId, createdAt: new Date().toISOString(),
|
||||
lineCount: 0, lastReceived: null, currentFileSize: 0,
|
||||
};
|
||||
this.streams.set(streamId, stream);
|
||||
this.buffers.set(streamId, []);
|
||||
this.fileSizes.set(streamId, 0);
|
||||
this.fileCreatedAt.set(streamId, Date.now());
|
||||
this.saveMetadata();
|
||||
return stream;
|
||||
}
|
||||
ingest(streamId, lines, streamName) {
|
||||
const stream = this.getOrCreateStream(streamId, streamName);
|
||||
const buffer = this.buffers.get(streamId);
|
||||
let accepted = 0, dropped = 0;
|
||||
const validLines = [];
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '')
|
||||
continue;
|
||||
if (buffer.length >= this.config.maxLinesPerStream) {
|
||||
buffer.shift();
|
||||
dropped++;
|
||||
}
|
||||
buffer.push(line);
|
||||
validLines.push(line);
|
||||
accepted++;
|
||||
}
|
||||
stream.lineCount = buffer.length;
|
||||
stream.lastReceived = new Date().toISOString();
|
||||
if (this.config.persistToDisk) {
|
||||
this.saveStream(streamId);
|
||||
stream.currentFileSize = this.fileSizes.get(streamId) || 0;
|
||||
this.checkSizeRotation(streamId);
|
||||
}
|
||||
// Forward to targets
|
||||
if (validLines.length > 0 && this.config.forwardTargets.length > 0) {
|
||||
this.forwardLines(streamId, validLines);
|
||||
}
|
||||
this.saveMetadata();
|
||||
return { accepted, dropped };
|
||||
}
|
||||
getStreams() { return Array.from(this.streams.values()); }
|
||||
readStream(streamId, tail) {
|
||||
const buffer = this.buffers.get(streamId);
|
||||
if (!buffer)
|
||||
throw new Error(`Stream not found: ${streamId}`);
|
||||
return tail && tail > 0 ? buffer.slice(-tail) : [...buffer];
|
||||
}
|
||||
deleteStream(streamId) {
|
||||
if (!this.streams.has(streamId))
|
||||
return false;
|
||||
this.streams.delete(streamId);
|
||||
this.buffers.delete(streamId);
|
||||
this.fileSizes.delete(streamId);
|
||||
this.fileCreatedAt.delete(streamId);
|
||||
const f = this.getStreamFilePath(streamId);
|
||||
if (fs.existsSync(f))
|
||||
fs.unlinkSync(f);
|
||||
this.saveMetadata();
|
||||
return true;
|
||||
}
|
||||
getConfig() { return { ...this.config }; }
|
||||
shutdown() {
|
||||
if (this.rotationTimer)
|
||||
clearInterval(this.rotationTimer);
|
||||
if (this.udpServer)
|
||||
this.udpServer.close();
|
||||
for (const timer of this.forwardTimers.values())
|
||||
clearTimeout(timer);
|
||||
}
|
||||
handleRequest(req, res, pathname) {
|
||||
const method = req.method || 'GET';
|
||||
// POST /collect/:streamId
|
||||
const collectMatch = pathname.match(/^\/collect\/([a-zA-Z0-9_\-\.]+)(\/json)?$/);
|
||||
if (collectMatch && method === 'POST') {
|
||||
const streamId = collectMatch[1];
|
||||
const isJson = !!collectMatch[2];
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk.toString(); });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
let lines;
|
||||
if (isJson) {
|
||||
const parsed = JSON.parse(body);
|
||||
lines = Array.isArray(parsed) ? parsed.map((e) => typeof e === 'string' ? e : JSON.stringify(e)) : [JSON.stringify(parsed)];
|
||||
}
|
||||
else {
|
||||
lines = body.split('\n');
|
||||
}
|
||||
const streamName = req.headers['x-stream-name'];
|
||||
const result = this.ingest(streamId, lines, streamName);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, ...result }));
|
||||
}
|
||||
catch (err) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// GET /collector/streams
|
||||
if (pathname === '/collector/streams' && method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(this.getStreams()));
|
||||
return true;
|
||||
}
|
||||
// GET /collector/config
|
||||
if (pathname === '/collector/config' && method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(this.getConfig()));
|
||||
return true;
|
||||
}
|
||||
// GET /collector/stream/:id
|
||||
const readMatch = pathname.match(/^\/collector\/stream\/([a-zA-Z0-9_\-\.]+)$/);
|
||||
if (readMatch && method === 'GET') {
|
||||
const streamId = readMatch[1];
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
const tail = url.searchParams.get('tail');
|
||||
const lines = this.readStream(streamId, tail ? parseInt(tail, 10) : undefined);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ streamId, lineCount: lines.length, lines }));
|
||||
}
|
||||
catch (err) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// DELETE /collector/stream/:id
|
||||
const deleteMatch = pathname.match(/^\/collector\/stream\/([a-zA-Z0-9_\-\.]+)$/);
|
||||
if (deleteMatch && method === 'DELETE') {
|
||||
const deleted = this.deleteStream(deleteMatch[1]);
|
||||
res.writeHead(deleted ? 200 : 404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: deleted }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
exports.LogCollector = LogCollector;
|
||||
//# sourceMappingURL=collector.js.map
|
||||
1
dist-server/collector.js.map
Normal file
1
dist-server/collector.js.map
Normal file
File diff suppressed because one or more lines are too long
101
dist-server/configFile.js
Normal file
101
dist-server/configFile.js
Normal file
@@ -0,0 +1,101 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getConfigFilePath = getConfigFilePath;
|
||||
exports.loadConfigFile = loadConfigFile;
|
||||
exports.saveConfigFile = saveConfigFile;
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
const CONFIG_FILE = 'logviewer.config.json';
|
||||
function getConfigFilePath() {
|
||||
return path.join(process.cwd(), CONFIG_FILE);
|
||||
}
|
||||
function loadConfigFile() {
|
||||
const defaults = {
|
||||
mode: 'both',
|
||||
port: 8080,
|
||||
host: '0.0.0.0',
|
||||
viewer: {
|
||||
sources: [],
|
||||
theme: 'dark',
|
||||
refreshInterval: 2000,
|
||||
tailMode: true,
|
||||
maxLines: 50000,
|
||||
defaultLevel: 'ALL',
|
||||
},
|
||||
collector: {
|
||||
enabled: true,
|
||||
storageDir: './collector-data',
|
||||
maxLinesPerStream: 100000,
|
||||
maxStreams: 50,
|
||||
persistToDisk: true,
|
||||
udp: { enabled: true, port: 5140 },
|
||||
rotation: { maxSizeBytes: 5242880, maxAgeMs: 600000, compression: 'gzip', fileExtension: '.log' },
|
||||
forwardTargets: [],
|
||||
},
|
||||
s3: { sources: {} },
|
||||
remoteCollector: { url: '' },
|
||||
};
|
||||
const filePath = getConfigFilePath();
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const fileContent = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
return deepMerge(defaults, fileContent);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`Error reading ${CONFIG_FILE}:`, err.message);
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
function saveConfigFile(config) {
|
||||
const filePath = getConfigFilePath();
|
||||
const existing = loadConfigFile();
|
||||
const merged = deepMerge(existing, config);
|
||||
fs.writeFileSync(filePath, JSON.stringify(merged, null, 2));
|
||||
}
|
||||
function deepMerge(target, source) {
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source)) {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
result[key] = deepMerge(target[key] || {}, source[key]);
|
||||
}
|
||||
else {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
//# sourceMappingURL=configFile.js.map
|
||||
1
dist-server/configFile.js.map
Normal file
1
dist-server/configFile.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"configFile.js","sourceRoot":"","sources":["../src/server/configFile.ts"],"names":[],"mappings":"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"}
|
||||
97
dist-server/configManager.js
Normal file
97
dist-server/configManager.js
Normal file
@@ -0,0 +1,97 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ConfigManager = void 0;
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
class ConfigManager {
|
||||
config;
|
||||
constructor() {
|
||||
this.config = this.loadConfig();
|
||||
}
|
||||
loadConfig() {
|
||||
this.loadEnvFile();
|
||||
const sourcesRaw = process.env.LOG_SOURCES || '';
|
||||
const sources = sourcesRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
return {
|
||||
sources,
|
||||
refreshInterval: parseInt(process.env.LOG_REFRESH_INTERVAL || '2000', 10),
|
||||
maxLines: parseInt(process.env.LOG_MAX_LINES || '50000', 10),
|
||||
theme: process.env.LOG_THEME || 'dark',
|
||||
defaultLevel: process.env.LOG_DEFAULT_LEVEL || 'ALL',
|
||||
tailMode: process.env.LOG_TAIL_MODE !== 'false',
|
||||
timestampRegex: process.env.LOG_TIMESTAMP_REGEX || null,
|
||||
};
|
||||
}
|
||||
loadEnvFile() {
|
||||
const envPaths = [
|
||||
path.join(process.cwd(), '.env'),
|
||||
path.join(__dirname, '..', '..', '.env'),
|
||||
];
|
||||
for (const envPath of envPaths) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
const content = fs.readFileSync(envPath, 'utf-8');
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
const value = trimmed.substring(eqIndex + 1).trim();
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
getConfig() {
|
||||
return { ...this.config };
|
||||
}
|
||||
updateSources(sources) {
|
||||
this.config.sources = sources;
|
||||
}
|
||||
getSources() {
|
||||
return [...this.config.sources];
|
||||
}
|
||||
}
|
||||
exports.ConfigManager = ConfigManager;
|
||||
//# sourceMappingURL=configManager.js.map
|
||||
1
dist-server/configManager.js.map
Normal file
1
dist-server/configManager.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"configManager.js","sourceRoot":"","sources":["../src/server/configManager.ts"],"names":[],"mappings":"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"}
|
||||
280
dist-server/logSourceManager.js
Normal file
280
dist-server/logSourceManager.js
Normal file
@@ -0,0 +1,280 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.LogSourceManager = void 0;
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
const zlib = __importStar(require("zlib"));
|
||||
const child_process_1 = require("child_process");
|
||||
class LogSourceManager {
|
||||
configManager;
|
||||
sources = new Map();
|
||||
constructor(configManager) {
|
||||
this.configManager = configManager;
|
||||
this.initializeSources();
|
||||
}
|
||||
initializeSources() {
|
||||
const configSources = this.configManager.getSources();
|
||||
for (const source of configSources) {
|
||||
this.addSource(source);
|
||||
}
|
||||
}
|
||||
addSource(sourcePath) {
|
||||
if (sourcePath.includes('*')) {
|
||||
const resolvedPaths = this.resolveGlob(sourcePath);
|
||||
for (const rp of resolvedPaths) {
|
||||
this.addSingleSource(rp);
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.addSingleSource(sourcePath);
|
||||
}
|
||||
this.configManager.updateSources(Array.from(this.sources.keys()));
|
||||
}
|
||||
addSingleSource(sourcePath) {
|
||||
const resolved = path.resolve(sourcePath);
|
||||
const type = this.detectType(resolved);
|
||||
let size = 0;
|
||||
let lastModified = null;
|
||||
let exists = false;
|
||||
try {
|
||||
const stats = fs.statSync(resolved);
|
||||
size = stats.size;
|
||||
lastModified = stats.mtime.toISOString();
|
||||
exists = true;
|
||||
}
|
||||
catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
this.sources.set(resolved, {
|
||||
path: resolved,
|
||||
displayName: path.basename(resolved),
|
||||
type,
|
||||
size,
|
||||
lastModified,
|
||||
exists,
|
||||
});
|
||||
}
|
||||
removeSource(sourcePath) {
|
||||
const resolved = path.resolve(sourcePath);
|
||||
this.sources.delete(resolved);
|
||||
this.configManager.updateSources(Array.from(this.sources.keys()));
|
||||
}
|
||||
getSources() {
|
||||
for (const [key, source] of this.sources) {
|
||||
try {
|
||||
const stats = fs.statSync(key);
|
||||
source.size = stats.size;
|
||||
source.lastModified = stats.mtime.toISOString();
|
||||
source.exists = true;
|
||||
}
|
||||
catch {
|
||||
source.exists = false;
|
||||
}
|
||||
}
|
||||
return Array.from(this.sources.values());
|
||||
}
|
||||
detectType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.gz':
|
||||
case '.gzip': return 'gzip';
|
||||
case '.bz2':
|
||||
case '.bzip2': return 'bzip2';
|
||||
case '.xz': return 'xz';
|
||||
case '.zst':
|
||||
case '.zstd': return 'zstd';
|
||||
default: return 'text';
|
||||
}
|
||||
}
|
||||
async readLog(sourcePath) {
|
||||
const resolved = path.resolve(sourcePath);
|
||||
const source = this.sources.get(resolved);
|
||||
if (!source)
|
||||
throw new Error(`Source not found: ${sourcePath}`);
|
||||
const config = this.configManager.getConfig();
|
||||
const content = this.readFileContent(resolved, source.type);
|
||||
const lines = content.split('\n');
|
||||
const maxLines = config.maxLines;
|
||||
const startIndex = lines.length > maxLines ? lines.length - maxLines : 0;
|
||||
const entries = [];
|
||||
for (let i = startIndex; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '')
|
||||
continue;
|
||||
entries.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
source: resolved,
|
||||
timestamp: this.extractTimestamp(line, config.timestampRegex),
|
||||
level: this.extractLevel(line),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
async readAllLogs() {
|
||||
const allEntries = [];
|
||||
for (const [sourcePath] of this.sources) {
|
||||
try {
|
||||
const entries = await this.readLog(sourcePath);
|
||||
allEntries.push(...entries);
|
||||
}
|
||||
catch (err) {
|
||||
allEntries.push({
|
||||
line: `[ERROR] Could not read ${sourcePath}: ${err.message}`,
|
||||
lineNumber: 0,
|
||||
source: sourcePath,
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'ERROR',
|
||||
});
|
||||
}
|
||||
}
|
||||
allEntries.sort((a, b) => {
|
||||
if (a.timestamp && b.timestamp)
|
||||
return a.timestamp.localeCompare(b.timestamp);
|
||||
return 0;
|
||||
});
|
||||
return allEntries;
|
||||
}
|
||||
readFileContent(filePath, type) {
|
||||
switch (type) {
|
||||
case 'gzip': return this.readGzip(filePath);
|
||||
case 'bzip2': return this.readBzip2(filePath);
|
||||
case 'xz': return this.readXz(filePath);
|
||||
case 'zstd': return this.readZstd(filePath);
|
||||
default: return fs.readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
}
|
||||
readGzip(filePath) {
|
||||
const compressed = fs.readFileSync(filePath);
|
||||
return zlib.gunzipSync(compressed).toString('utf-8');
|
||||
}
|
||||
readBzip2(filePath) {
|
||||
try {
|
||||
return (0, child_process_1.execSync)(`bzcat "${filePath}"`, { maxBuffer: 100 * 1024 * 1024 }).toString('utf-8');
|
||||
}
|
||||
catch {
|
||||
throw new Error('Cannot decompress bzip2. Make sure bzip2 is installed.');
|
||||
}
|
||||
}
|
||||
readXz(filePath) {
|
||||
try {
|
||||
return (0, child_process_1.execSync)(`xzcat "${filePath}"`, { maxBuffer: 100 * 1024 * 1024 }).toString('utf-8');
|
||||
}
|
||||
catch {
|
||||
throw new Error('Cannot decompress xz. Make sure xz-utils is installed.');
|
||||
}
|
||||
}
|
||||
readZstd(filePath) {
|
||||
try {
|
||||
return (0, child_process_1.execSync)(`zstdcat "${filePath}"`, { maxBuffer: 100 * 1024 * 1024 }).toString('utf-8');
|
||||
}
|
||||
catch {
|
||||
throw new Error('Cannot decompress zstd. Make sure zstd is installed.');
|
||||
}
|
||||
}
|
||||
extractTimestamp(line, customRegex) {
|
||||
const patterns = [
|
||||
/(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/,
|
||||
/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/,
|
||||
/\[(\d{2}\/[A-Z][a-z]{2}\/\d{4}:\d{2}:\d{2}:\d{2}\s[+-]\d{4})\]/,
|
||||
/(\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2})/,
|
||||
];
|
||||
if (customRegex) {
|
||||
try {
|
||||
const match = line.match(new RegExp(customRegex));
|
||||
if (match)
|
||||
return match[1] || match[0];
|
||||
}
|
||||
catch { /* skip */ }
|
||||
}
|
||||
for (const pattern of patterns) {
|
||||
const match = line.match(pattern);
|
||||
if (match)
|
||||
return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
extractLevel(line) {
|
||||
const upper = line.toUpperCase();
|
||||
const levels = ['FATAL', 'ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'TRACE', 'VERBOSE'];
|
||||
for (const level of levels) {
|
||||
if (new RegExp(`\\b${level}\\b`).test(upper)) {
|
||||
return level === 'WARNING' ? 'WARN' : level;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
resolveGlob(pattern) {
|
||||
const dir = path.dirname(pattern);
|
||||
const filePattern = path.basename(pattern);
|
||||
const results = [];
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
const regex = new RegExp('^' + filePattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
|
||||
for (const file of files) {
|
||||
if (regex.test(file)) {
|
||||
const fullPath = path.join(dir, file);
|
||||
try {
|
||||
if (fs.statSync(fullPath).isFile())
|
||||
results.push(fullPath);
|
||||
}
|
||||
catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* dir doesn't exist */ }
|
||||
return results;
|
||||
}
|
||||
async getFileStats(sourcePath) {
|
||||
const resolved = path.resolve(sourcePath);
|
||||
try {
|
||||
const stats = fs.statSync(resolved);
|
||||
const source = this.sources.get(resolved);
|
||||
const content = this.readFileContent(resolved, source?.type || 'text');
|
||||
return {
|
||||
size: stats.size,
|
||||
lastModified: stats.mtime.toISOString(),
|
||||
lineCount: content.split('\n').length,
|
||||
type: source?.type || 'text',
|
||||
};
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.LogSourceManager = LogSourceManager;
|
||||
//# sourceMappingURL=logSourceManager.js.map
|
||||
1
dist-server/logSourceManager.js.map
Normal file
1
dist-server/logSourceManager.js.map
Normal file
File diff suppressed because one or more lines are too long
516
dist-server/monitor.js
Normal file
516
dist-server/monitor.js
Normal file
@@ -0,0 +1,516 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.LogMonitor = void 0;
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
const zlib = __importStar(require("zlib"));
|
||||
const http = __importStar(require("http"));
|
||||
const https = __importStar(require("https"));
|
||||
// ---- Monitor Engine ----
|
||||
class LogMonitor {
|
||||
config;
|
||||
scanTimer = null;
|
||||
results = [];
|
||||
constructor(config = {}) {
|
||||
this.config = {
|
||||
enabled: config.enabled !== false,
|
||||
scanIntervalMs: config.scanIntervalMs || 60000,
|
||||
rules: config.rules || [],
|
||||
storageDir: config.storageDir || path.join(process.cwd(), 'monitor-data'),
|
||||
};
|
||||
if (!fs.existsSync(this.config.storageDir)) {
|
||||
fs.mkdirSync(this.config.storageDir, { recursive: true });
|
||||
}
|
||||
this.loadState();
|
||||
if (this.config.enabled && this.config.rules.length > 0) {
|
||||
this.startScanning();
|
||||
}
|
||||
}
|
||||
loadState() {
|
||||
const stateFile = path.join(this.config.storageDir, '_monitor_state.json');
|
||||
if (fs.existsSync(stateFile)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
this.results = data.results || [];
|
||||
// Restore lastTriggered from state
|
||||
if (data.rules) {
|
||||
for (const saved of data.rules) {
|
||||
const rule = this.config.rules.find(r => r.id === saved.id);
|
||||
if (rule) {
|
||||
rule.lastTriggered = saved.lastTriggered;
|
||||
rule.matchCount = saved.matchCount || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
saveState() {
|
||||
const stateFile = path.join(this.config.storageDir, '_monitor_state.json');
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
results: this.results.slice(-100),
|
||||
rules: this.config.rules.map(r => ({ id: r.id, lastTriggered: r.lastTriggered, matchCount: r.matchCount })),
|
||||
}, null, 2));
|
||||
}
|
||||
startScanning() {
|
||||
if (this.scanTimer)
|
||||
clearInterval(this.scanTimer);
|
||||
console.log(` Monitor scanning every ${this.config.scanIntervalMs / 1000}s with ${this.config.rules.length} rule(s)`);
|
||||
this.scanTimer = setInterval(() => this.runScan(), this.config.scanIntervalMs);
|
||||
// Run immediately
|
||||
setTimeout(() => this.runScan(), 2000);
|
||||
}
|
||||
stopScanning() {
|
||||
if (this.scanTimer) {
|
||||
clearInterval(this.scanTimer);
|
||||
this.scanTimer = null;
|
||||
}
|
||||
}
|
||||
async runScan() {
|
||||
for (const rule of this.config.rules) {
|
||||
if (!rule.enabled)
|
||||
continue;
|
||||
// Check schedule
|
||||
if (rule.schedule && rule.schedule.mode !== 'interval') {
|
||||
if (!this.shouldRunBySchedule(rule))
|
||||
continue;
|
||||
}
|
||||
// Check cooldown
|
||||
if (rule.lastTriggered) {
|
||||
const elapsed = Date.now() - new Date(rule.lastTriggered).getTime();
|
||||
if (elapsed < rule.cooldownMs)
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const matches = this.scanRule(rule);
|
||||
if (matches.length > 0) {
|
||||
const result = {
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
matchedLines: matches,
|
||||
totalMatches: matches.length,
|
||||
sources: rule.sources,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
rule.lastTriggered = result.timestamp;
|
||||
rule.matchCount += matches.length;
|
||||
this.results.push(result);
|
||||
// Send notifications
|
||||
await this.notify(rule, result);
|
||||
this.saveState();
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(` Monitor rule "${rule.name}" error:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
shouldRunBySchedule(rule) {
|
||||
const schedule = rule.schedule;
|
||||
if (!schedule)
|
||||
return true;
|
||||
const now = new Date();
|
||||
const lastRun = rule.lastTriggered ? new Date(rule.lastTriggered) : null;
|
||||
switch (schedule.mode) {
|
||||
case 'daily': {
|
||||
// Run once per day at specified time (HH:MM)
|
||||
const [h, m] = (schedule.value || '07:00').split(':').map(Number);
|
||||
const targetToday = new Date(now);
|
||||
targetToday.setHours(h, m, 0, 0);
|
||||
// Already ran today?
|
||||
if (lastRun && lastRun.toDateString() === now.toDateString())
|
||||
return false;
|
||||
// Is it past the target time?
|
||||
return now >= targetToday;
|
||||
}
|
||||
case 'hourly': {
|
||||
// Run every N hours
|
||||
const hours = parseInt(schedule.value || '2', 10);
|
||||
if (!lastRun)
|
||||
return true;
|
||||
return (now.getTime() - lastRun.getTime()) >= hours * 3600000;
|
||||
}
|
||||
case 'cron': {
|
||||
// Simple cron: just check if enough time passed based on scan interval
|
||||
// Full cron parsing would need a library, so we approximate
|
||||
return true;
|
||||
}
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
scanRule(rule) {
|
||||
const allMatches = [];
|
||||
const regexes = rule.patterns.map(p => new RegExp(p, 'i'));
|
||||
for (const source of rule.sources) {
|
||||
const files = this.resolveFiles(source);
|
||||
for (const file of files) {
|
||||
if (!this.isInTimeRange(file, rule.timeRange))
|
||||
continue;
|
||||
try {
|
||||
const content = this.readFile(file);
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '')
|
||||
continue;
|
||||
const matched = rule.patternLogic === 'all'
|
||||
? regexes.every(r => r.test(line))
|
||||
: regexes.some(r => r.test(line));
|
||||
if (matched)
|
||||
allMatches.push(line);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
return allMatches;
|
||||
}
|
||||
resolveFiles(pattern) {
|
||||
if (pattern.includes('*')) {
|
||||
const dir = path.dirname(pattern);
|
||||
const filePattern = path.basename(pattern);
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
const regex = new RegExp('^' + filePattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
||||
return files.filter(f => regex.test(f)).map(f => path.join(dir, f));
|
||||
}
|
||||
catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return fs.existsSync(pattern) ? [pattern] : [];
|
||||
}
|
||||
isInTimeRange(file, range) {
|
||||
if (range.type === 'all')
|
||||
return true;
|
||||
try {
|
||||
const stat = fs.statSync(file);
|
||||
const fileTime = stat.mtime.getTime();
|
||||
const now = Date.now();
|
||||
switch (range.type) {
|
||||
case 'today': {
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
return fileTime >= startOfDay.getTime();
|
||||
}
|
||||
case 'week': return fileTime >= now - 7 * 24 * 60 * 60 * 1000;
|
||||
case 'month': return fileTime >= now - 30 * 24 * 60 * 60 * 1000;
|
||||
case 'hours': return fileTime >= now - (range.hours || 24) * 60 * 60 * 1000;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
readFile(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.gz' || ext === '.gzip') {
|
||||
return zlib.gunzipSync(fs.readFileSync(filePath)).toString('utf-8');
|
||||
}
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
// ---- Notifications ----
|
||||
async notify(rule, result) {
|
||||
const summary = this.buildSummary(rule, result);
|
||||
const attachment = result.matchedLines.length > rule.maxLinesInMessage
|
||||
? this.buildAttachment(result)
|
||||
: null;
|
||||
for (const channel of rule.notifications) {
|
||||
try {
|
||||
switch (channel.type) {
|
||||
case 'webhook':
|
||||
await this.sendWebhook(channel, summary, attachment);
|
||||
break;
|
||||
case 'telegram':
|
||||
await this.sendTelegram(channel, summary, attachment);
|
||||
break;
|
||||
case 'teams':
|
||||
await this.sendTeams(channel, summary);
|
||||
break;
|
||||
case 'email':
|
||||
console.log(` [Monitor] Email notification not yet implemented`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(` [Monitor] Notification error (${channel.type}):`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
buildSummary(rule, result) {
|
||||
const lines = result.matchedLines.slice(0, rule.maxLinesInMessage);
|
||||
let msg = `🚨 **Log Monitor Alert: ${rule.name}**\n\n`;
|
||||
msg += `**Matches:** ${result.totalMatches} lines\n`;
|
||||
msg += `**Sources:** ${result.sources.join(', ')}\n`;
|
||||
msg += `**Time:** ${result.timestamp}\n`;
|
||||
msg += `**Patterns:** ${rule.patterns.join(' | ')}\n\n`;
|
||||
msg += `**Sample (first ${lines.length} of ${result.totalMatches}):**\n`;
|
||||
msg += '```\n' + lines.join('\n') + '\n```';
|
||||
if (result.totalMatches > rule.maxLinesInMessage) {
|
||||
msg += `\n\n📎 Full results (${result.totalMatches} lines) attached as .gz file.`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
buildAttachment(result) {
|
||||
const content = result.matchedLines.join('\n');
|
||||
return zlib.gzipSync(Buffer.from(content, 'utf-8'));
|
||||
}
|
||||
async sendWebhook(channel, message, attachment) {
|
||||
if (!channel.url)
|
||||
return;
|
||||
const payload = JSON.stringify({
|
||||
text: message,
|
||||
matches: attachment ? `(${attachment.length} bytes gzipped attachment)` : undefined,
|
||||
});
|
||||
await this.httpPost(channel.url, payload, 'application/json');
|
||||
console.log(` [Monitor] Webhook sent to ${channel.url}`);
|
||||
}
|
||||
async sendTelegram(channel, message, attachment) {
|
||||
if (!channel.botToken || !channel.chatId)
|
||||
return;
|
||||
// Send text message - use plain text to avoid Markdown parsing issues
|
||||
const payload = JSON.stringify({
|
||||
chat_id: channel.chatId,
|
||||
text: message.replace(/[*_`\[]/g, '').substring(0, 4000), // Strip markdown, respect limit
|
||||
});
|
||||
await this.httpPost(`https://api.telegram.org/bot${channel.botToken}/sendMessage`, payload, 'application/json');
|
||||
console.log(` [Monitor] Telegram sent to chat ${channel.chatId}`);
|
||||
}
|
||||
async sendTeams(channel, message) {
|
||||
if (!channel.url)
|
||||
return;
|
||||
const payload = JSON.stringify({
|
||||
'@type': 'MessageCard',
|
||||
summary: 'Log Monitor Alert',
|
||||
text: message.replace(/\n/g, '<br>').replace(/```/g, ''),
|
||||
});
|
||||
await this.httpPost(channel.url, payload, 'application/json');
|
||||
console.log(` [Monitor] Teams webhook sent`);
|
||||
}
|
||||
httpPost(targetUrl, body, contentType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(targetUrl);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
const req = transport.request({
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (isHttps ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(body) },
|
||||
}, (res) => {
|
||||
let responseBody = '';
|
||||
res.on('data', (c) => { responseBody += c; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${responseBody.substring(0, 200)}`));
|
||||
}
|
||||
else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
// ---- Public API ----
|
||||
getRules() { return this.config.rules; }
|
||||
getResults() { return this.results; }
|
||||
getConfig() { return { ...this.config }; }
|
||||
addRule(rule) {
|
||||
this.config.rules.push(rule);
|
||||
this.saveState();
|
||||
if (this.config.rules.length === 1 && !this.scanTimer)
|
||||
this.startScanning();
|
||||
}
|
||||
updateRule(id, updates) {
|
||||
const rule = this.config.rules.find(r => r.id === id);
|
||||
if (!rule)
|
||||
return false;
|
||||
Object.assign(rule, updates);
|
||||
this.saveState();
|
||||
return true;
|
||||
}
|
||||
deleteRule(id) {
|
||||
const idx = this.config.rules.findIndex(r => r.id === id);
|
||||
if (idx === -1)
|
||||
return false;
|
||||
this.config.rules.splice(idx, 1);
|
||||
this.saveState();
|
||||
return true;
|
||||
}
|
||||
triggerRule(id) {
|
||||
const rule = this.config.rules.find(r => r.id === id);
|
||||
if (!rule)
|
||||
return null;
|
||||
const matches = this.scanRule(rule);
|
||||
if (matches.length === 0)
|
||||
return null;
|
||||
const result = {
|
||||
ruleId: rule.id, ruleName: rule.name, matchedLines: matches,
|
||||
totalMatches: matches.length, sources: rule.sources, timestamp: new Date().toISOString(),
|
||||
};
|
||||
this.results.push(result);
|
||||
rule.matchCount += matches.length;
|
||||
this.saveState();
|
||||
return result;
|
||||
}
|
||||
getAttachment(ruleId) {
|
||||
const result = [...this.results].reverse().find(r => r.ruleId === ruleId);
|
||||
if (!result)
|
||||
return null;
|
||||
return zlib.gzipSync(Buffer.from(result.matchedLines.join('\n'), 'utf-8'));
|
||||
}
|
||||
handleRequest(req, res, pathname) {
|
||||
const method = req.method || 'GET';
|
||||
if (pathname === '/api/monitor/rules' && method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(this.getRules()));
|
||||
return true;
|
||||
}
|
||||
if (pathname === '/api/monitor/rules' && method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const rule = JSON.parse(body);
|
||||
if (!rule.id)
|
||||
rule.id = 'rule-' + Date.now();
|
||||
if (!rule.maxLinesInMessage)
|
||||
rule.maxLinesInMessage = 20;
|
||||
if (!rule.cooldownMs)
|
||||
rule.cooldownMs = 300000;
|
||||
if (!rule.matchCount)
|
||||
rule.matchCount = 0;
|
||||
if (!rule.lastTriggered)
|
||||
rule.lastTriggered = null;
|
||||
this.addRule(rule);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, rule }));
|
||||
}
|
||||
catch (e) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// Test notification endpoint
|
||||
if (pathname === '/api/monitor/test-notification' && method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const channel = JSON.parse(body);
|
||||
const testMsg = `🧪 **Test Notification**\n\nThis is a test from LogViewer Monitor.\nTimestamp: ${new Date().toISOString()}\n\nIf you see this, notifications are working! ✅`;
|
||||
await this.sendTestNotification(channel, testMsg);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
catch (e) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, error: e.message }));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const ruleMatch = pathname.match(/^\/api\/monitor\/rule\/([a-zA-Z0-9_\-]+)$/);
|
||||
if (ruleMatch && method === 'DELETE') {
|
||||
const ok = this.deleteRule(ruleMatch[1]);
|
||||
res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok }));
|
||||
return true;
|
||||
}
|
||||
if (ruleMatch && method === 'PUT') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
const updates = JSON.parse(body);
|
||||
const ok = this.updateRule(ruleMatch[1], updates);
|
||||
res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const triggerMatch = pathname.match(/^\/api\/monitor\/trigger\/([a-zA-Z0-9_\-]+)$/);
|
||||
if (triggerMatch && method === 'POST') {
|
||||
const result = this.triggerRule(triggerMatch[1]);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result || { matches: 0 }));
|
||||
return true;
|
||||
}
|
||||
if (pathname === '/api/monitor/results' && method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(this.results.slice(-50)));
|
||||
return true;
|
||||
}
|
||||
if (pathname === '/api/monitor/config' && method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(this.getConfig()));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async sendTestNotification(channel, message) {
|
||||
switch (channel.type) {
|
||||
case 'webhook':
|
||||
if (!channel.url)
|
||||
throw new Error('No webhook URL provided');
|
||||
await this.sendWebhook(channel, message, null);
|
||||
break;
|
||||
case 'telegram':
|
||||
if (!channel.botToken || !channel.chatId)
|
||||
throw new Error('Bot token and chat ID required');
|
||||
await this.sendTelegram(channel, message, null);
|
||||
break;
|
||||
case 'teams':
|
||||
if (!channel.url)
|
||||
throw new Error('No Teams webhook URL provided');
|
||||
await this.sendTeams(channel, message);
|
||||
break;
|
||||
case 'email':
|
||||
throw new Error('Email test not yet implemented');
|
||||
default:
|
||||
throw new Error(`Unknown channel type: ${channel.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.LogMonitor = LogMonitor;
|
||||
//# sourceMappingURL=monitor.js.map
|
||||
1
dist-server/monitor.js.map
Normal file
1
dist-server/monitor.js.map
Normal file
File diff suppressed because one or more lines are too long
192
dist-server/s3Source.js
Normal file
192
dist-server/s3Source.js
Normal file
@@ -0,0 +1,192 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.S3Source = void 0;
|
||||
const https = __importStar(require("https"));
|
||||
const http = __importStar(require("http"));
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const zlib = __importStar(require("zlib"));
|
||||
/**
|
||||
* Minimal S3 client without external dependencies.
|
||||
* Supports AWS S3 and S3-compatible services (MinIO, DigitalOcean Spaces, etc.)
|
||||
*/
|
||||
class S3Source {
|
||||
config;
|
||||
host;
|
||||
useHttps;
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
if (config.endpoint) {
|
||||
const url = new URL(config.endpoint);
|
||||
this.host = url.host;
|
||||
this.useHttps = url.protocol === 'https:';
|
||||
}
|
||||
else {
|
||||
this.host = `${config.bucket}.s3.${config.region}.amazonaws.com`;
|
||||
this.useHttps = true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* List objects in the bucket with optional prefix
|
||||
*/
|
||||
async listObjects(prefix) {
|
||||
const usedPrefix = prefix || this.config.prefix || '';
|
||||
const queryParams = usedPrefix ? `list-type=2&prefix=${encodeURIComponent(usedPrefix)}` : 'list-type=2';
|
||||
const path = this.config.endpoint ? `/${this.config.bucket}/?${queryParams}` : `/?${queryParams}`;
|
||||
const response = await this.request('GET', path, '');
|
||||
const objects = [];
|
||||
// Simple XML parsing for S3 ListObjectsV2 response
|
||||
const contentRegex = /<Contents>([\s\S]*?)<\/Contents>/g;
|
||||
let match;
|
||||
while ((match = contentRegex.exec(response)) !== null) {
|
||||
const content = match[1];
|
||||
const key = this.extractXmlValue(content, 'Key');
|
||||
const size = parseInt(this.extractXmlValue(content, 'Size') || '0', 10);
|
||||
const lastModified = this.extractXmlValue(content, 'LastModified') || '';
|
||||
if (key && !key.endsWith('/')) {
|
||||
objects.push({ key, size, lastModified });
|
||||
}
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
/**
|
||||
* Download an object's content as string
|
||||
*/
|
||||
async getObject(key) {
|
||||
const path = this.config.endpoint
|
||||
? `/${this.config.bucket}/${encodeURIComponent(key)}`
|
||||
: `/${encodeURIComponent(key)}`;
|
||||
const response = await this.requestRaw('GET', path, '');
|
||||
// Auto-decompress if gzipped
|
||||
if (key.endsWith('.gz') || key.endsWith('.gzip')) {
|
||||
try {
|
||||
const decompressed = zlib.gunzipSync(response);
|
||||
return decompressed.toString('utf-8');
|
||||
}
|
||||
catch {
|
||||
return response.toString('utf-8');
|
||||
}
|
||||
}
|
||||
return response.toString('utf-8');
|
||||
}
|
||||
extractXmlValue(xml, tag) {
|
||||
const regex = new RegExp(`<${tag}>(.*?)</${tag}>`);
|
||||
const match = xml.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
async request(method, path, body) {
|
||||
const buf = await this.requestRaw(method, path, body);
|
||||
return buf.toString('utf-8');
|
||||
}
|
||||
async requestRaw(method, path, body) {
|
||||
const now = new Date();
|
||||
const dateStamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
const shortDate = dateStamp.substring(0, 8);
|
||||
const headers = {
|
||||
'host': this.host,
|
||||
'x-amz-date': dateStamp,
|
||||
'x-amz-content-sha256': this.sha256(body),
|
||||
};
|
||||
// Create canonical request
|
||||
const [pathPart, queryPart] = path.split('?');
|
||||
const canonicalQueryString = queryPart || '';
|
||||
const signedHeaderKeys = Object.keys(headers).sort();
|
||||
const signedHeaders = signedHeaderKeys.join(';');
|
||||
const canonicalHeaders = signedHeaderKeys.map(k => `${k}:${headers[k]}\n`).join('');
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
pathPart,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
headers['x-amz-content-sha256'],
|
||||
].join('\n');
|
||||
// Create string to sign
|
||||
const credentialScope = `${shortDate}/${this.config.region}/s3/aws4_request`;
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
dateStamp,
|
||||
credentialScope,
|
||||
this.sha256(canonicalRequest),
|
||||
].join('\n');
|
||||
// Calculate signature
|
||||
const signingKey = this.getSignatureKey(shortDate);
|
||||
const signature = this.hmac(signingKey, stringToSign).toString('hex');
|
||||
// Authorization header
|
||||
headers['authorization'] = `AWS4-HMAC-SHA256 Credential=${this.config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: this.host.split(':')[0],
|
||||
port: this.host.includes(':') ? parseInt(this.host.split(':')[1]) : (this.useHttps ? 443 : 80),
|
||||
path: path,
|
||||
method: method,
|
||||
headers: headers,
|
||||
};
|
||||
const transport = this.useHttps ? https : http;
|
||||
const req = transport.request(options, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const data = Buffer.concat(chunks);
|
||||
if (res.statusCode && res.statusCode >= 400) {
|
||||
reject(new Error(`S3 error ${res.statusCode}: ${data.toString('utf-8').substring(0, 200)}`));
|
||||
}
|
||||
else {
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(30000, () => { req.destroy(); reject(new Error('S3 request timeout')); });
|
||||
if (body)
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
sha256(data) {
|
||||
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
|
||||
}
|
||||
hmac(key, data) {
|
||||
return crypto.createHmac('sha256', key).update(data, 'utf8').digest();
|
||||
}
|
||||
getSignatureKey(dateStamp) {
|
||||
const kDate = this.hmac(`AWS4${this.config.secretAccessKey}`, dateStamp);
|
||||
const kRegion = this.hmac(kDate, this.config.region);
|
||||
const kService = this.hmac(kRegion, 's3');
|
||||
return this.hmac(kService, 'aws4_request');
|
||||
}
|
||||
}
|
||||
exports.S3Source = S3Source;
|
||||
//# sourceMappingURL=s3Source.js.map
|
||||
1
dist-server/s3Source.js.map
Normal file
1
dist-server/s3Source.js.map
Normal file
File diff suppressed because one or more lines are too long
448
dist-server/server.js
Normal file
448
dist-server/server.js
Normal file
@@ -0,0 +1,448 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const http = __importStar(require("http"));
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
const url = __importStar(require("url"));
|
||||
const logSourceManager_1 = require("./logSourceManager");
|
||||
const configManager_1 = require("./configManager");
|
||||
const collector_1 = require("./collector");
|
||||
const s3Source_1 = require("./s3Source");
|
||||
const configFile_1 = require("./configFile");
|
||||
const monitor_1 = require("./monitor");
|
||||
const configManager = new configManager_1.ConfigManager();
|
||||
const logSourceManager = new logSourceManager_1.LogSourceManager(configManager);
|
||||
// Mode: "viewer" | "collector" | "monitor" | "both" | "all"
|
||||
const MODE = (process.env.LOG_MODE || 'both').toLowerCase();
|
||||
const PORT = parseInt(process.env.PORT || '8080', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
// Collector setup
|
||||
let collector = null;
|
||||
if (MODE === 'collector' || MODE === 'both') {
|
||||
let forwardTargets = [];
|
||||
try {
|
||||
forwardTargets = JSON.parse(process.env.COLLECTOR_FORWARD_TARGETS || '[]');
|
||||
}
|
||||
catch { }
|
||||
collector = new collector_1.LogCollector({
|
||||
storageDir: process.env.COLLECTOR_STORAGE_DIR || path.join(process.cwd(), 'collector-data'),
|
||||
maxLinesPerStream: parseInt(process.env.COLLECTOR_MAX_LINES || '100000', 10),
|
||||
maxStreams: parseInt(process.env.COLLECTOR_MAX_STREAMS || '50', 10),
|
||||
udpEnabled: process.env.COLLECTOR_UDP_ENABLED !== 'false',
|
||||
udpPort: parseInt(process.env.COLLECTOR_UDP_PORT || '5140', 10),
|
||||
persistToDisk: process.env.COLLECTOR_PERSIST !== 'false',
|
||||
rotation: {
|
||||
maxSizeBytes: parseInt(process.env.COLLECTOR_ROTATION_MAX_SIZE || '5242880', 10),
|
||||
maxAgeMs: parseInt(process.env.COLLECTOR_ROTATION_MAX_AGE || '600000', 10),
|
||||
compression: process.env.COLLECTOR_ROTATION_COMPRESSION || 'gzip',
|
||||
fileExtension: process.env.COLLECTOR_ROTATION_EXTENSION || '.log',
|
||||
},
|
||||
forwardTargets,
|
||||
});
|
||||
}
|
||||
// S3 sources from env
|
||||
const s3Sources = new Map();
|
||||
function loadS3SourcesFromEnv() {
|
||||
// Format: S3_SOURCE_<name>=bucket:prefix
|
||||
// S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION (global)
|
||||
// Or per-source: S3_SOURCE_<name>_ACCESS_KEY, S3_SOURCE_<name>_SECRET_KEY, etc.
|
||||
const globalAccessKey = process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID || '';
|
||||
const globalSecretKey = process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY || '';
|
||||
const globalRegion = process.env.S3_REGION || process.env.AWS_REGION || 'us-east-1';
|
||||
const globalEndpoint = process.env.S3_ENDPOINT || '';
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith('S3_SOURCE_') && value && !key.includes('_ACCESS_KEY') && !key.includes('_SECRET_KEY') && !key.includes('_REGION') && !key.includes('_ENDPOINT')) {
|
||||
const name = key.replace('S3_SOURCE_', '').toLowerCase();
|
||||
const parts = value.split(':');
|
||||
const bucket = parts[0];
|
||||
const prefix = parts.slice(1).join(':') || '';
|
||||
const accessKey = process.env[`S3_SOURCE_${name.toUpperCase()}_ACCESS_KEY`] || globalAccessKey;
|
||||
const secretKey = process.env[`S3_SOURCE_${name.toUpperCase()}_SECRET_KEY`] || globalSecretKey;
|
||||
const region = process.env[`S3_SOURCE_${name.toUpperCase()}_REGION`] || globalRegion;
|
||||
const endpoint = process.env[`S3_SOURCE_${name.toUpperCase()}_ENDPOINT`] || globalEndpoint;
|
||||
if (accessKey && secretKey && bucket) {
|
||||
const config = { accessKeyId: accessKey, secretAccessKey: secretKey, region, bucket, prefix: prefix || undefined, endpoint: endpoint || undefined };
|
||||
s3Sources.set(name, new s3Source_1.S3Source(config));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loadS3SourcesFromEnv();
|
||||
// Monitor setup
|
||||
let monitor = null;
|
||||
if (MODE === 'monitor' || MODE === 'both' || MODE === 'all') {
|
||||
let monitorRules = [];
|
||||
try {
|
||||
monitorRules = JSON.parse(process.env.MONITOR_RULES || '[]');
|
||||
}
|
||||
catch { }
|
||||
monitor = new monitor_1.LogMonitor({
|
||||
enabled: process.env.MONITOR_ENABLED !== 'false',
|
||||
scanIntervalMs: parseInt(process.env.MONITOR_SCAN_INTERVAL || '60000', 10),
|
||||
rules: monitorRules,
|
||||
storageDir: process.env.MONITOR_STORAGE_DIR || path.join(process.cwd(), 'monitor-data'),
|
||||
});
|
||||
}
|
||||
// Remote collector URL (for viewer mode connecting to a remote collector)
|
||||
const REMOTE_COLLECTOR_URL = process.env.REMOTE_COLLECTOR_URL || '';
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
function sendJson(res, data, status = 200) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
function sendError(res, message, status = 500) {
|
||||
sendJson(res, { error: message }, status);
|
||||
}
|
||||
async function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
async function fetchRemote(remotePath) {
|
||||
if (!REMOTE_COLLECTOR_URL)
|
||||
throw new Error('No remote collector configured');
|
||||
const fullUrl = REMOTE_COLLECTOR_URL.replace(/\/$/, '') + remotePath;
|
||||
return new Promise((resolve, reject) => {
|
||||
const transport = fullUrl.startsWith('https') ? require('https') : require('http');
|
||||
transport.get(fullUrl, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
||||
}
|
||||
catch {
|
||||
resolve(Buffer.concat(chunks).toString('utf-8'));
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const parsedUrl = url.parse(req.url || '/', true);
|
||||
const pathname = parsedUrl.pathname || '/';
|
||||
const method = req.method || 'GET';
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Stream-Name');
|
||||
if (method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
// ---- Collector endpoints ----
|
||||
if (collector && (pathname.startsWith('/collect/') || pathname.startsWith('/collector/'))) {
|
||||
const handled = collector.handleRequest(req, res, pathname);
|
||||
if (handled)
|
||||
return;
|
||||
}
|
||||
// ---- Monitor endpoints ----
|
||||
if (monitor && pathname.startsWith('/api/monitor/')) {
|
||||
const handled = monitor.handleRequest(req, res, pathname);
|
||||
if (handled)
|
||||
return;
|
||||
}
|
||||
// ---- API Routes ----
|
||||
if (pathname.startsWith('/api/')) {
|
||||
try {
|
||||
// GET /api/mode
|
||||
if (pathname === '/api/mode' && method === 'GET') {
|
||||
return sendJson(res, { mode: MODE, hasCollector: !!collector, hasRemoteCollector: !!REMOTE_COLLECTOR_URL, s3Sources: Array.from(s3Sources.keys()) });
|
||||
}
|
||||
// GET /api/config
|
||||
if (pathname === '/api/config' && method === 'GET') {
|
||||
return sendJson(res, { ...configManager.getConfig(), mode: MODE });
|
||||
}
|
||||
// GET /api/sources
|
||||
if (pathname === '/api/sources' && method === 'GET') {
|
||||
return sendJson(res, logSourceManager.getSources());
|
||||
}
|
||||
// POST /api/sources { path: "..." }
|
||||
if (pathname === '/api/sources' && method === 'POST') {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
logSourceManager.addSource(body.path);
|
||||
return sendJson(res, logSourceManager.getSources());
|
||||
}
|
||||
// DELETE /api/sources?path=...
|
||||
if (pathname === '/api/sources' && method === 'DELETE') {
|
||||
const sourcePath = parsedUrl.query.path;
|
||||
if (!sourcePath)
|
||||
return sendError(res, 'Missing path parameter', 400);
|
||||
logSourceManager.removeSource(sourcePath);
|
||||
return sendJson(res, logSourceManager.getSources());
|
||||
}
|
||||
// GET /api/logs?source=...
|
||||
if (pathname === '/api/logs' && method === 'GET') {
|
||||
const source = parsedUrl.query.source;
|
||||
if (source && source !== 'ALL') {
|
||||
const entries = await logSourceManager.readLog(source);
|
||||
return sendJson(res, entries);
|
||||
}
|
||||
else {
|
||||
const entries = await logSourceManager.readAllLogs();
|
||||
return sendJson(res, entries);
|
||||
}
|
||||
}
|
||||
// ---- S3 endpoints ----
|
||||
// POST /api/s3/add { name, bucket, prefix, accessKeyId, secretAccessKey, region, endpoint? }
|
||||
if (pathname === '/api/s3/add' && method === 'POST') {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
const { name, bucket, prefix, accessKeyId, secretAccessKey, region, endpoint } = body;
|
||||
if (!name || !bucket || !accessKeyId || !secretAccessKey || !region) {
|
||||
return sendError(res, 'Missing required fields: name, bucket, accessKeyId, secretAccessKey, region', 400);
|
||||
}
|
||||
const config = { accessKeyId, secretAccessKey, region, bucket, prefix, endpoint };
|
||||
s3Sources.set(name, new s3Source_1.S3Source(config));
|
||||
return sendJson(res, { ok: true, name });
|
||||
}
|
||||
// GET /api/s3/sources
|
||||
if (pathname === '/api/s3/sources' && method === 'GET') {
|
||||
return sendJson(res, Array.from(s3Sources.keys()));
|
||||
}
|
||||
// GET /api/s3/list?name=...&prefix=...
|
||||
if (pathname === '/api/s3/list' && method === 'GET') {
|
||||
const name = parsedUrl.query.name;
|
||||
const prefix = parsedUrl.query.prefix;
|
||||
const s3 = s3Sources.get(name);
|
||||
if (!s3)
|
||||
return sendError(res, `S3 source not found: ${name}`, 404);
|
||||
const objects = await s3.listObjects(prefix);
|
||||
return sendJson(res, objects);
|
||||
}
|
||||
// GET /api/s3/read?name=...&key=...
|
||||
if (pathname === '/api/s3/read' && method === 'GET') {
|
||||
const name = parsedUrl.query.name;
|
||||
const key = parsedUrl.query.key;
|
||||
const s3 = s3Sources.get(name);
|
||||
if (!s3)
|
||||
return sendError(res, `S3 source not found: ${name}`, 404);
|
||||
if (!key)
|
||||
return sendError(res, 'Missing key parameter', 400);
|
||||
const content = await s3.getObject(key);
|
||||
const lines = content.split('\n').filter(l => l.trim() !== '');
|
||||
const entries = lines.map((line, i) => ({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
source: `s3://${name}/${key}`,
|
||||
timestamp: extractTimestamp(line),
|
||||
level: extractLevel(line),
|
||||
}));
|
||||
return sendJson(res, entries);
|
||||
}
|
||||
// DELETE /api/s3/remove?name=...
|
||||
if (pathname === '/api/s3/remove' && method === 'DELETE') {
|
||||
const name = parsedUrl.query.name;
|
||||
if (!name)
|
||||
return sendError(res, 'Missing name parameter', 400);
|
||||
s3Sources.delete(name);
|
||||
return sendJson(res, { ok: true });
|
||||
}
|
||||
// ---- Remote Collector endpoints (viewer reads from remote collector) ----
|
||||
// GET /api/remote/streams
|
||||
if (pathname === '/api/remote/streams' && method === 'GET') {
|
||||
if (!REMOTE_COLLECTOR_URL)
|
||||
return sendJson(res, []);
|
||||
try {
|
||||
const streams = await fetchRemote('/collector/streams');
|
||||
return sendJson(res, streams);
|
||||
}
|
||||
catch (err) {
|
||||
return sendError(res, `Cannot reach remote collector: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// GET /api/remote/stream/:id?tail=N
|
||||
const remoteStreamMatch = pathname.match(/^\/api\/remote\/stream\/([a-zA-Z0-9_\-\.]+)$/);
|
||||
if (remoteStreamMatch && method === 'GET') {
|
||||
const streamId = remoteStreamMatch[1];
|
||||
const tail = parsedUrl.query.tail;
|
||||
const tailParam = tail ? `?tail=${tail}` : '';
|
||||
try {
|
||||
const data = await fetchRemote(`/collector/stream/${streamId}${tailParam}`);
|
||||
// Convert to LogEntry format
|
||||
const lines = data.lines || [];
|
||||
const entries = lines.map((line, i) => ({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
source: `collector://${streamId}`,
|
||||
timestamp: extractTimestamp(line),
|
||||
level: extractLevel(line),
|
||||
}));
|
||||
return sendJson(res, entries);
|
||||
}
|
||||
catch (err) {
|
||||
return sendError(res, err.message);
|
||||
}
|
||||
}
|
||||
// ---- Collector streams as log source (local) ----
|
||||
// GET /api/collector/streams
|
||||
if (pathname === '/api/collector/streams' && method === 'GET') {
|
||||
if (!collector)
|
||||
return sendJson(res, []);
|
||||
return sendJson(res, collector.getStreams());
|
||||
}
|
||||
// GET /api/collector/stream/:id
|
||||
const localStreamMatch = pathname.match(/^\/api\/collector\/stream\/([a-zA-Z0-9_\-\.]+)$/);
|
||||
if (localStreamMatch && method === 'GET') {
|
||||
const streamId = localStreamMatch[1];
|
||||
if (!collector)
|
||||
return sendError(res, 'Collector not enabled', 400);
|
||||
try {
|
||||
const tail = parsedUrl.query.tail;
|
||||
const lines = collector.readStream(streamId, tail ? parseInt(tail, 10) : undefined);
|
||||
const entries = lines.map((line, i) => ({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
source: `stream://${streamId}`,
|
||||
timestamp: extractTimestamp(line),
|
||||
level: extractLevel(line),
|
||||
}));
|
||||
return sendJson(res, entries);
|
||||
}
|
||||
catch (err) {
|
||||
return sendError(res, err.message, 404);
|
||||
}
|
||||
}
|
||||
// POST /api/config/save - Save config to file
|
||||
if (pathname === '/api/config/save' && method === 'POST') {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
(0, configFile_1.saveConfigFile)(body);
|
||||
return sendJson(res, { ok: true });
|
||||
}
|
||||
return sendError(res, 'Not found', 404);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('API error:', err);
|
||||
return sendError(res, err.message);
|
||||
}
|
||||
}
|
||||
// ---- Static Files ----
|
||||
const webDir = path.join(__dirname, '..', 'src', 'web');
|
||||
let filePath;
|
||||
// Serve collector UI when in collector mode, monitor UI when in monitor mode
|
||||
if (pathname === '/' || pathname === '/index.html') {
|
||||
if (MODE === 'collector') {
|
||||
filePath = path.join(webDir, 'collector.html');
|
||||
}
|
||||
else if (MODE === 'monitor') {
|
||||
filePath = path.join(webDir, 'monitor.html');
|
||||
}
|
||||
else {
|
||||
filePath = path.join(webDir, 'index.html');
|
||||
}
|
||||
}
|
||||
else {
|
||||
filePath = path.join(webDir, pathname);
|
||||
}
|
||||
const webRoot = path.resolve(webDir);
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!resolvedPath.startsWith(webRoot)) {
|
||||
res.writeHead(403);
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!fs.existsSync(resolvedPath) || fs.statSync(resolvedPath).isDirectory()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(resolvedPath);
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(fs.readFileSync(resolvedPath));
|
||||
}
|
||||
catch {
|
||||
res.writeHead(500);
|
||||
res.end('Internal server error');
|
||||
}
|
||||
});
|
||||
// Helper functions
|
||||
function extractTimestamp(line) {
|
||||
const patterns = [
|
||||
/(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/,
|
||||
/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/,
|
||||
/\[(\d{2}\/[A-Z][a-z]{2}\/\d{4}:\d{2}:\d{2}:\d{2}\s[+-]\d{4})\]/,
|
||||
];
|
||||
for (const p of patterns) {
|
||||
const m = line.match(p);
|
||||
if (m)
|
||||
return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function extractLevel(line) {
|
||||
const upper = line.toUpperCase();
|
||||
for (const level of ['FATAL', 'ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'TRACE']) {
|
||||
if (new RegExp(`\\b${level}\\b`).test(upper))
|
||||
return level === 'WARNING' ? 'WARN' : level;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log('');
|
||||
console.log(' ╔══════════════════════════════════════════════════╗');
|
||||
console.log(` ║ 📋 Log Viewer - Mode: ${MODE.toUpperCase().padEnd(24)}║`);
|
||||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||||
console.log(` ║ URL: http://${HOST}:${PORT} ║`);
|
||||
console.log(` ║ Sources: ${String(logSourceManager.getSources().length).padEnd(3)} file(s) ║`);
|
||||
console.log(` ║ S3 Sources: ${String(s3Sources.size).padEnd(3)} configured ║`);
|
||||
console.log(` ║ Collector: ${collector ? 'ACTIVE' : 'OFF'} ║`);
|
||||
console.log(` ║ Monitor: ${monitor ? 'ACTIVE' : 'OFF'} ║`);
|
||||
if (REMOTE_COLLECTOR_URL) {
|
||||
console.log(` ║ Remote: ${REMOTE_COLLECTOR_URL.substring(0, 30).padEnd(30)} ║`);
|
||||
}
|
||||
console.log(' ╚══════════════════════════════════════════════════╝');
|
||||
console.log('');
|
||||
if (collector) {
|
||||
console.log(' Collector endpoints:');
|
||||
console.log(` POST http://${HOST}:${PORT}/collect/<stream-id> (plain text, one line per line)`);
|
||||
console.log(` POST http://${HOST}:${PORT}/collect/<stream-id>/json (JSON array of entries)`);
|
||||
console.log('');
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=server.js.map
|
||||
1
dist-server/server.js.map
Normal file
1
dist-server/server.js.map
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user