Initial commit: LogMaster with Viewer Collector Monitor modes
Some checks failed
LogMaster CI/CD / build-and-test (push) Has been cancelled
LogMaster CI/CD / docker (push) Has been cancelled

This commit is contained in:
2026-05-04 13:50:15 +02:00
commit 44cd9ab001
231 changed files with 29018 additions and 0 deletions

516
dist-server/monitor.js Normal file
View 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