516 lines
21 KiB
JavaScript
516 lines
21 KiB
JavaScript
"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
|