Initial commit: LogMaster with Viewer Collector Monitor modes
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user