From 0c3b75d7a805fa89836951aff6d69d959d9ee69d Mon Sep 17 00:00:00 2001 From: janis steiner Date: Sat, 16 May 2026 12:35:01 +0200 Subject: [PATCH] adding new features --- public/index.html | 284 ++++++++++++++++++++++++++++++++++-- src/Api/Router.php | 193 +++++++++++++++++++++++- src/Storage/Database.php | 12 ++ src/Storage/Repository.php | 149 +++++++++++++++++-- src/Worker/Orchestrator.php | 13 ++ 5 files changed, 623 insertions(+), 28 deletions(-) diff --git a/public/index.html b/public/index.html index 3345067..d9f4216 100644 --- a/public/index.html +++ b/public/index.html @@ -152,12 +152,19 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius +
- + @@ -180,6 +187,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
Log Search
+
@@ -187,7 +195,11 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
- Use * as wildcard. bla matches "blabla". * shows all logs. Separate terms match all (AND). +
+ Use * as wildcard. bla matches "blabla". * shows all logs. Separate terms match all (AND). + + +
@@ -259,6 +271,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
Health
checking...
+
DB Size
Auth Server
auth.jakach.ch
Logged in as
DB Path
/app/data/logging.db
@@ -274,6 +287,34 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius

Rules — PHP regex patterns, e.g. /error/i

+
+
Data Retention
+
+

Auto-purge old data to keep the database small.

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
Audit Log
+
+
+
ID ID Severity Status Message
+ +
Loading...
+
+
+
@@ -450,6 +491,29 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
+ + + + + +
@@ -961,9 +1025,12 @@ document.getElementById('ruleForm').addEventListener('submit', async e => { // --- SETTINGS --- async function loadSettings() { try { - await api('/health'); - document.getElementById('sysHealth').textContent = 'Healthy'; - document.getElementById('sysHealth').className = 'badge bg-success'; + const health = await api('/health'); + document.getElementById('sysHealth').textContent = health.status === 'ok' ? 'Healthy' : 'Degraded'; + document.getElementById('sysHealth').className = 'badge bg-' + (health.status === 'ok' ? 'success' : 'warning'); + if (health.db_size) { + document.getElementById('sysDbSize').textContent = health.db_size; + } } catch { document.getElementById('sysHealth').textContent = 'Unreachable'; document.getElementById('sysHealth').className = 'badge bg-danger'; @@ -988,6 +1055,14 @@ async function loadSettings() { try { await loadFalsePositives(); } catch (e) { console.error('load false positives error', e); } + + try { + const ret = await api('/system/retention'); + if (ret.log_days) document.getElementById('retentionLogDays').value = ret.log_days; + if (ret.alert_days) document.getElementById('retentionAlertDays').value = ret.alert_days; + } catch (e) { /* not critical */ } + + loadAuditLog(); } document.getElementById('saveTokensBtn').addEventListener('click', async () => { @@ -1098,6 +1173,38 @@ document.getElementById('addFpBtn').addEventListener('click', async () => { } catch (e) { toast('Failed to add', 'danger'); } }); +// --- RULE TEST --- +document.getElementById('ruleTestBtn').addEventListener('click', async () => { + const line = document.getElementById('ruleTestInput').value.trim(); + if (!line) { toast('Enter a log line', 'danger'); return; } + try { + const res = await api('/rules/test', { method: 'POST', body: JSON.stringify({ line }) }); + const matches = res.data || []; + const results = document.getElementById('ruleTestResults'); + if (!matches.length) { + results.innerHTML = '
No rules matched this line.
'; + } else { + results.innerHTML = matches.map(m => `
${m.is_false_positive ? 'Blocked by false positive — ' : ''}${esc(m.rule_name)} → ${severityBadge(m.severity)}
`).join(''); + } + results.style.display = 'block'; + } catch (e) { toast('Test failed', 'danger'); } +}); + +document.querySelector('[data-page="rules"]').addEventListener('click', () => { + setTimeout(() => { + if (!document.getElementById('ruleTestModal')) return; + const toolbar = document.querySelector('#page-rules .d-flex'); + if (toolbar && !toolbar.querySelector('#openRuleTestBtn')) { + const btn = document.createElement('button'); + btn.className = 'btn btn-outline-secondary btn-sm ms-2'; + btn.id = 'openRuleTestBtn'; + btn.innerHTML = ' Test Rule'; + btn.onclick = () => bootstrap.Modal.getOrCreateInstance(document.getElementById('ruleTestModal')).show(); + toolbar.querySelector('.btn-primary').after(btn); + } + }, 100); +}); + // --- LOGS --- async function loadLogs(query) { if (!query) { @@ -1109,14 +1216,20 @@ async function loadLogs(query) { } } + let url = '/logs/search?q=' + encodeURIComponent(query) + '&limit=200'; + const since = document.getElementById('logSince').value; + const until = document.getElementById('logUntil').value; + if (since) url += '&since=' + encodeURIComponent(since); + if (until) url += '&until=' + encodeURIComponent(until); + try { - const res = await api('/logs/search?q=' + encodeURIComponent(query) + '&limit=200'); + const res = await api(url); const entries = res.data || []; const tbody = document.getElementById('logsBody'); if (!entries.length) { tbody.innerHTML = '

No results for "' + esc(query) + '"

'; } else { - tbody.innerHTML = entries.map(e => ` + tbody.innerHTML = entries.map(e => ` #${e.id} ${new Date(e.created_at).toLocaleString()} ${esc(e.line)} @@ -1127,8 +1240,161 @@ async function loadLogs(query) { } catch (e) { console.error('logs error', e); } } -document.getElementById('logSearchBtn').addEventListener('click', () => loadLogs()); -document.getElementById('logSearchInput').addEventListener('keydown', e => { if (e.key === 'Enter') loadLogs(); }); +async function showLogContext(id) { + try { + const res = await api('/logs/context/' + id + '?before=5&after=5'); + const ctx = document.getElementById('contextBody'); + let html = ''; + if (res.before) res.before.forEach(l => { + html += `
#${l.id} ${new Date(l.created_at).toLocaleString()} ${esc(l.line)}
`; + }); + if (res.current) { + html += `
#${res.current.id} ${new Date(res.current.created_at).toLocaleString()} ${esc(res.current.line)}
`; + } + if (res.after) res.after.forEach(l => { + html += `
#${l.id} ${new Date(l.created_at).toLocaleString()} ${esc(l.line)}
`; + }); + ctx.innerHTML = html || '

No context found

'; + bootstrap.Modal.getOrCreateInstance(document.getElementById('contextModal')).show(); +} catch (e) { toast('Failed to load context', 'danger'); } +} + +async function exportLogs() { + const query = document.getElementById('logSearchInput').value.trim(); + if (!query) { toast('Search something first', 'warning'); return; } + const since = document.getElementById('logSince').value; + const until = document.getElementById('logUntil').value; + let url = '/logs/search?q=' + encodeURIComponent(query) + '&limit=10000'; + if (since) url += '&since=' + encodeURIComponent(since); + if (until) url += '&until=' + encodeURIComponent(until); + try { + const res = await api(url); + const entries = res.data || []; + if (!entries.length) { toast('No results to export', 'warning'); return; } + const csv = ['ID,Line,Source,Level,Created', ...entries.map(e => + [e.id, '"' + (e.line || '').replace(/"/g, '""') + '"', + e.source_name || '', e.level || '', e.created_at].join(','))].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'logs.csv'; + a.click(); + toast('Exported ' + entries.length + ' rows'); + } catch (e) { toast('Export failed', 'danger'); } +} + +// --- BULK ALERT OPERATIONS --- +let selectedAlertIds = new Set(); + +function toggleAlertSelection(id) { + if (selectedAlertIds.has(id)) selectedAlertIds.delete(id); + else selectedAlertIds.add(id); + document.getElementById('bulkBar').classList.toggle('d-none', selectedAlertIds.size === 0); + document.getElementById('selectedCount').textContent = selectedAlertIds.size; +} + +function renderAlerts(query) { + let sorted = [...state.alerts]; + const field = state.sortField; + const dir = state.sortDir; + sorted.sort((a, b) => { + let va = a[field], vb = b[field]; + if (field === 'severity') { + const order = ['debug','info','notice','warning_low','warning','warning_high','error','critical_low','critical','critical_high','emergency']; + va = order.indexOf(va); + vb = order.indexOf(vb); + } else if (field === 'created_at') { + va = new Date(va).getTime(); + vb = new Date(vb).getTime(); + } else { + va = (va || '').toString().toLowerCase(); + vb = (vb || '').toString().toLowerCase(); + if (va < vb) return dir === 'asc' ? -1 : 1; + if (va > vb) return dir === 'asc' ? 1 : -1; + return 0; + } + return dir === 'asc' ? va - vb : vb - va; + }); + + const tbody = document.getElementById('alertsBody'); + if (!sorted.length) { + tbody.innerHTML = '

No alerts match those filters

'; + } else { + tbody.innerHTML = sorted.map(a => ` + + + #${a.id} + + ${severityBadge(a.severity)} + ${statusBadge(a.status)} + ${esc(a.message)} + ${esc(a.source_name || '—')} + ${new Date(a.created_at).toLocaleString()} + + ${a.status === 'open' ? `` : ''} + ${a.status !== 'resolved' ? `` : ''} + + `).join(''); + } + const label = document.getElementById('searchInput').value.trim() ? 'search results' : 'alerts'; + document.getElementById('alertsCount').textContent = sorted.length + ' ' + label; + document.getElementById('bulkBar').classList.toggle('d-none', selectedAlertIds.size === 0); +} + +async function bulkAction(status) { + const ids = Array.from(selectedAlertIds); + if (!ids.length) return; + try { + await api('/alerts/bulk', { method: 'POST', body: JSON.stringify({ ids, status }) }); + toast(ids.length + ' alerts ' + status); + selectedAlertIds.clear(); + loadAlerts(); + } catch (e) { toast('Bulk action failed', 'danger'); } +} + +async function exportAlerts(format) { + const ids = Array.from(selectedAlertIds); + if (!ids.length) { toast('Select alerts first', 'warning'); return; } + try { + const res = await api('/alerts/export', { method: 'POST', body: JSON.stringify({ ids, format }) }); + if (format === 'json') { + const blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'alerts.json'; + a.click(); + } + toast('Export done'); + } catch (e) { toast('Export failed', 'danger'); } +} + +// --- RETENTION --- +document.getElementById('runRetentionBtn').addEventListener('click', async () => { + const logDays = document.getElementById('retentionLogDays').value; + const alertDays = document.getElementById('retentionAlertDays').value; + const el = document.getElementById('retentionResult'); + el.textContent = 'Purging...'; + try { + const res = await api('/system/retention', { method: 'POST', body: JSON.stringify({ log_days: parseInt(logDays), alert_days: parseInt(alertDays) }) }); + el.textContent = res.log_entries_deleted + ' logs, ' + res.alerts_deleted + ' alerts purged'; + el.className = 'ms-2 text-success'; + setTimeout(() => { el.textContent = ''; }, 5000); + } catch (e) { el.textContent = 'Failed'; el.className = 'ms-2 text-danger'; } +}); + +// --- AUDIT LOG --- +async function loadAuditLog() { + try { + const res = await api('/system/audit-log'); + const entries = res.data || []; + const tbody = document.getElementById('auditLogBody'); + if (!entries.length) { + tbody.innerHTML = 'No audit entries'; + } else { + tbody.innerHTML = entries.map(e => `${new Date(e.created_at).toLocaleString()} ${esc(e.action)} ${esc(e.entity_type)}${e.entity_id ? ' #' + e.entity_id : ''}${e.username ? ' by ' + esc(e.username) : ''}${e.details ? '
' + esc(e.details) + '' : ''}`).join(''); + } + } catch (e) { console.error('audit log error', e); } +} // --- Helpers --- function esc(s) { diff --git a/src/Api/Router.php b/src/Api/Router.php index 6f77ac0..0797ce9 100644 --- a/src/Api/Router.php +++ b/src/Api/Router.php @@ -13,6 +13,7 @@ class Router private AuthMiddleware $auth; private Engine $engine; private TelegramNotifier $telegram; + private ?string $currentUser = null; public function __construct() { @@ -23,6 +24,11 @@ class Router $this->telegram = new TelegramNotifier($this->repo); } + private function user(): ?string + { + return $this->currentUser; + } + public function handle(): void { $method = $_SERVER['REQUEST_METHOD']; @@ -49,10 +55,11 @@ class Router echo json_encode(['error' => 'Unauthorized', 'login_url' => $this->loginUrl()]); return; } + $this->currentUser = $user['username'] ?? null; } $result = match (true) { - $path === '/health' && $method === 'GET' => ['status' => 'ok'], + $path === '/health' && $method === 'GET' => $this->health(), $path === '/auth/csrf' && $method === 'GET' => $this->csrfToken(), @@ -70,6 +77,7 @@ class Router $path === '/rules' && $method === 'GET' => $this->repo->getRules(), $path === '/rules' && $method === 'POST' => $this->requireCsrf(fn() => $this->createRule()), + $path === '/rules/test' && $method === 'POST' => $this->requireCsrf(fn() => $this->testRule()), preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'DELETE' => $this->requireCsrf(fn() => $this->deleteEntity('rule', (int) $m[1])), preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'PUT' @@ -77,14 +85,28 @@ class Router $path === '/alerts' && $method === 'GET' => $this->getAlerts(), $path === '/alerts/search' && $method === 'GET' => $this->searchAlerts(), + $path === '/alerts/bulk' && $method === 'POST' => $this->requireCsrf(fn() => $this->bulkAlertAction()), preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST' => $this->requireCsrf(fn() => $this->ackAlert((int) $m[1])), preg_match('#^/alerts/(\d+)/status$#', $path, $m) && $method === 'POST' => $this->requireCsrf(fn() => $this->updateAlertStatus((int) $m[1])), preg_match('#^/alerts/counts$#', $path) && $method === 'GET' => $this->repo->getAlertCounts(), + preg_match('#^/alerts/export$#', $path) && $method === 'POST' + => $this->requireCsrf(fn() => $this->exportAlerts()), + preg_match('#^/logs/context/(\d+)$#', $path, $m) && $method === 'GET' + => $this->logContext((int) $m[1]), $path === '/logs/search' && $method === 'GET' => $this->searchLogs(), + preg_match('#^/logs/export$#', $path) && $method === 'POST' + => $this->requireCsrf(fn() => $this->exportLogs()), + + $path === '/system/retention' && $method === 'POST' + => $this->requireCsrf(fn() => $this->runRetention()), + $path === '/system/retention' && $method === 'GET' + => $this->getRetentionConfig(), + $path === '/system/audit-log' && $method === 'GET' + => $this->getAuditLog(), $path === '/config/allowed_tokens' && $method === 'GET' => ['tokens' => $this->repo->getAllowedUserTokens()], @@ -140,6 +162,31 @@ class Router echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } + private function health(): array + { + $dbOk = true; + $dbSize = 'unknown'; + try { + $this->repo->getAlerts(1); + $dbPath = '/app/data/logging.db'; + if (file_exists($dbPath)) { + $bytes = filesize($dbPath); + $dbSize = $bytes > 1073741824 ? round($bytes / 1073741824, 1) . ' GB' + : ($bytes > 1048576 ? round($bytes / 1048576, 1) . ' MB' + : round($bytes / 1024, 1) . ' KB'); + } + } catch (\Throwable) { + $dbOk = false; + } + + return [ + 'status' => $dbOk ? 'ok' : 'degraded', + 'database' => $dbOk ? 'connected' : 'error', + 'db_size' => $dbSize, + 'time' => date('c'), + ]; + } + private function loginUrl(): string { $redirect = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/oauth.php'; @@ -177,32 +224,42 @@ class Router { $body = json_decode(file_get_contents('php://input'), true); $type = LogSourceType::from($body['type'] ?? ''); - return $this->repo->createSource( + $result = $this->repo->createSource( name: $body['name'], type: $type, address: $body['address'], labels: $body['labels'] ?? [], ); + $this->repo->logAudit('create', 'source', $result->id, 'Source "' . ($body['name'] ?? '') . '" created', $this->user()); + return $result; } private function createRule(): mixed { $body = json_decode(file_get_contents('php://input'), true); - return $this->repo->createRule( + $result = $this->repo->createRule( name: $body['name'], pattern: $body['pattern'], severity: $body['severity'] ?? 'warning', rateLimitSeconds: $body['rate_limit_seconds'] ?? null, ); + $this->repo->logAudit('create', 'rule', $result->id, 'Rule "' . ($body['name'] ?? '') . '" created', $this->user()); + return $result; } private function deleteEntity(string $type, int $id): array { + $name = ''; if ($type === 'source') { + $s = $this->repo->getSource($id); + $name = $s ? $s->name : ''; $this->repo->deleteSource($id); } else { + $r = $this->repo->getRule($id); + $name = $r ? $r->name : ''; $this->repo->deleteRule($id); } + $this->repo->logAudit('delete', $type, $id, ucfirst($type) . ' "' . $name . '" deleted', $this->user()); return ['status' => 'deleted', 'id' => $id]; } @@ -223,7 +280,7 @@ class Router private function updateRule(int $id): mixed { $body = json_decode(file_get_contents('php://input'), true); - return $this->repo->updateRule( + $result = $this->repo->updateRule( id: $id, name: $body['name'], pattern: $body['pattern'], @@ -231,6 +288,8 @@ class Router rateLimitSeconds: $body['rate_limit_seconds'] ?? null, active: $body['active'] ?? true, ); + $this->repo->logAudit('update', 'rule', $id, 'Rule "' . ($body['name'] ?? '') . '" updated', $this->user()); + return $result; } private function getAlerts(): mixed @@ -239,12 +298,13 @@ class Router $offset = (int) ($_GET['offset'] ?? 0); $status = $_GET['status'] ?? null; $severity = $_GET['severity'] ?? null; - return $this->repo->getAlerts($limit, $offset, $status, $severity); + return $this->repo->getAlerts($limit, $offset, $status, $severity, $_GET['since'] ?? null, $_GET['until'] ?? null); } private function ackAlert(int $id): array { $this->repo->updateAlertStatus($id, AlertStatus::Acknowledged); + $this->repo->logAudit('acknowledge', 'alert', $id, null, $this->user()); return ['status' => 'acknowledged', 'id' => $id]; } @@ -257,6 +317,7 @@ class Router return ['error' => 'Invalid status. Use: open, acknowledged, resolved']; } $this->repo->updateAlertStatus($id, $status); + $this->repo->logAudit('status_change', 'alert', $id, 'Status set to ' . $status->value, $this->user()); return ['status' => $status->value, 'id' => $id]; } @@ -278,7 +339,7 @@ class Router } $limit = (int) ($_GET['limit'] ?? 200); $offset = (int) ($_GET['offset'] ?? 0); - return ['data' => $this->repo->searchLogEntries($query, $limit, $offset)]; + return ['data' => $this->repo->searchLogEntries($query, $limit, $offset, $_GET['since'] ?? null, $_GET['until'] ?? null)]; } private function updateAllowedTokens(): array @@ -286,6 +347,7 @@ class Router $body = json_decode(file_get_contents('php://input'), true); $tokens = $body['tokens'] ?? []; $this->repo->setAllowedUserTokens($tokens); + $this->repo->logAudit('update', 'config', null, 'Allowed tokens updated', $this->user()); return ['status' => 'saved', 'tokens' => $this->repo->getAllowedUserTokens()]; } @@ -327,6 +389,7 @@ class Router $body = json_decode(file_get_contents('php://input'), true); $this->repo->setConfig('telegram_bot_token', $body['bot_token'] ?? ''); $this->repo->setConfig('telegram_chat_id', $body['chat_id'] ?? ''); + $this->repo->logAudit('update', 'config', null, 'Telegram config updated', $this->user()); return $this->getTelegramConfig(); } @@ -352,6 +415,7 @@ class Router private function deleteFalsePositive(int $id): array { $this->repo->deleteFalsePositive($id); + $this->repo->logAudit('delete', 'false_positive', $id, null, $this->user()); return ['status' => 'deleted', 'id' => $id]; } @@ -369,4 +433,121 @@ class Router } return $fn(); } + + // --- New Handlers --- + + private function testRule(): array + { + $body = json_decode(file_get_contents('php://input'), true); + $line = $body['line'] ?? ''; + if (empty($line)) { + http_response_code(400); + return ['error' => 'Missing "line" field']; + } + + $rules = $this->repo->getActiveRules(); + $matches = []; + foreach ($rules as $rule) { + $delimiter = $rule->pattern[0] ?? '/'; + if (preg_match($rule->pattern, $line)) { + $matches[] = [ + 'rule_id' => $rule->id, + 'rule_name' => $rule->name, + 'severity' => $rule->severity->value, + 'is_false_positive' => $this->repo->isFalsePositive($line), + ]; + } + } + + return ['data' => $matches, 'line' => substr($line, 0, 200)]; + } + + private function bulkAlertAction(): array + { + $body = json_decode(file_get_contents('php://input'), true); + $ids = $body['ids'] ?? []; + $status = AlertStatus::tryFrom($body['status'] ?? ''); + if (empty($ids) || !$status) { + http_response_code(400); + return ['error' => 'Missing "ids" array or valid "status"']; + } + $count = $this->repo->bulkUpdateAlertStatus($ids, $status); + $this->repo->logAudit('bulk_status', 'alert', null, count($ids) . ' alerts set to ' . $status->value, $this->user()); + return ['updated' => $count]; + } + + private function logContext(int $id): array + { + $before = (int) ($_GET['before'] ?? 5); + $after = (int) ($_GET['after'] ?? 5); + return $this->repo->getLogContext($id, min($before, 50), min($after, 50)); + } + + private function exportAlerts(): never + { + $body = json_decode(file_get_contents('php://input'), true); + $ids = $body['ids'] ?? []; + $format = $body['format'] ?? 'json'; + $alerts = $this->repo->exportAlerts($ids); + + if ($format === 'csv') { + header('Content-Type: text/csv'); + header('Content-Disposition: attachment; filename="alerts.csv"'); + $out = fopen('php://output', 'w'); + fputcsv($out, ['ID', 'Rule', 'Severity', 'Status', 'Message', 'Source', 'Created']); + foreach ($alerts as $a) { + fputcsv($out, [$a->id, $a->ruleName, $a->severity->value, $a->status->value, $a->message, $a->sourceName ?? '', $a->createdAt->format('c')]); + } + fclose($out); + } else { + echo json_encode(['data' => array_map(fn($a) => $a->toArray(), $alerts)], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + exit; + } + + private function exportLogs(): never + { + $body = json_decode(file_get_contents('php://input'), true); + $ids = $body['ids'] ?? []; + $format = $body['format'] ?? 'json'; + $logs = $this->repo->exportLogs($ids); + + if ($format === 'csv') { + header('Content-Type: text/csv'); + header('Content-Disposition: attachment; filename="logs.csv"'); + $out = fopen('php://output', 'w'); + fputcsv($out, ['ID', 'Line', 'Source', 'Level', 'Created']); + foreach ($logs as $l) { + fputcsv($out, [$l['id'], $l['line'], $l['source_name'] ?? '', $l['level'] ?? '', $l['created_at']]); + } + fclose($out); + } else { + echo json_encode(['data' => $logs], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + exit; + } + + private function runRetention(): array + { + $body = json_decode(file_get_contents('php://input'), true); + $logDays = (int) ($body['log_days'] ?? 30); + $alertDays = (int) ($body['alert_days'] ?? 90); + $result = $this->repo->purgeOldData($logDays, $alertDays); + $this->repo->logAudit('retention_purge', 'system', null, json_encode($result), $this->user()); + return $result; + } + + private function getRetentionConfig(): array + { + return [ + 'log_days' => (int) ($this->repo->getConfig('retention_log_days', '30')), + 'alert_days' => (int) ($this->repo->getConfig('retention_alert_days', '90')), + ]; + } + + private function getAuditLog(): array + { + $limit = (int) ($_GET['limit'] ?? 50); + return ['data' => $this->repo->getAuditLog($limit)]; + } } \ No newline at end of file diff --git a/src/Storage/Database.php b/src/Storage/Database.php index 445f981..47152bc 100644 --- a/src/Storage/Database.php +++ b/src/Storage/Database.php @@ -181,5 +181,17 @@ $this->pdo->exec(" created_at TEXT DEFAULT (datetime('now')) ) "); + + $this->pdo->exec(" + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + details TEXT, + username TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + "); } } \ No newline at end of file diff --git a/src/Storage/Repository.php b/src/Storage/Repository.php index dfc16bf..3128a6a 100644 --- a/src/Storage/Repository.php +++ b/src/Storage/Repository.php @@ -121,7 +121,7 @@ class Repository return $row ? Alert::fromRow($row) : null; } - public function getAlerts(int $limit = 100, int $offset = 0, ?string $status = null, ?string $severity = null): array + public function getAlerts(int $limit = 100, int $offset = 0, ?string $status = null, ?string $severity = null, ?string $since = null, ?string $until = null): array { $where = []; $params = []; @@ -134,6 +134,14 @@ class Repository $where[] = 'severity = ?'; $params[] = $severity; } + if ($since) { + $where[] = 'created_at >= ?'; + $params[] = $since; + } + if ($until) { + $where[] = 'created_at <= ?'; + $params[] = $until; + } $sql = "SELECT * FROM alerts"; if ($where) { @@ -187,29 +195,52 @@ class Repository $stmt->execute([$line, $sourceId, $sourceName, $level]); } - public function searchLogEntries(string $query, int $limit = 200, int $offset = 0): array + public function searchLogEntries(string $query, int $limit = 200, int $offset = 0, ?string $since = null, ?string $until = null): array { $query = trim($query); if ($query === '') { return []; } + $where = []; + $params = []; + + if ($since) { + $where[] = 'e.created_at >= ?'; + $params[] = $since; + } + if ($until) { + $where[] = 'e.created_at <= ?'; + $params[] = $until; + } + if ($query === '*') { - $stmt = $this->db->pdo()->prepare( - "SELECT * FROM log_entries ORDER BY created_at DESC LIMIT ? OFFSET ?" - ); - $stmt->execute([$limit, $offset]); + $sql = "SELECT * FROM log_entries e"; + if ($where) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= " ORDER BY e.created_at DESC LIMIT ? OFFSET ?"; + $params[] = $limit; + $params[] = $offset; + $stmt = $this->db->pdo()->prepare($sql); + $stmt->execute($params); return $stmt->fetchAll(); } $like = $this->toLikePattern($query); - $stmt = $this->db->pdo()->prepare( - "SELECT e.* FROM log_entries e - WHERE e.line LIKE ? - ORDER BY e.created_at DESC - LIMIT ? OFFSET ?" - ); - $stmt->execute([$like, $limit, $offset]); + $where[] = 'e.line LIKE ?'; + $params[] = $like; + $sql = "SELECT e.* FROM log_entries e"; + + if ($where) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= " ORDER BY e.created_at DESC LIMIT ? OFFSET ?"; + $params[] = $limit; + $params[] = $offset; + + $stmt = $this->db->pdo()->prepare($sql); + $stmt->execute($params); return $stmt->fetchAll(); } @@ -338,4 +369,96 @@ class Repository } return false; } + + // --- Audit Log --- + + public function logAudit(string $action, string $entityType, ?int $entityId = null, ?string $details = null, ?string $username = null): void + { + $stmt = $this->db->pdo()->prepare( + "INSERT INTO audit_log (action, entity_type, entity_id, details, username) VALUES (?, ?, ?, ?, ?)" + ); + $stmt->execute([$action, $entityType, $entityId, $details, $username]); + } + + public function getAuditLog(int $limit = 50): array + { + return $this->db->pdo()->prepare( + "SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?" + )->execute([$limit])->fetchAll(); + } + + // --- Retention --- + + public function purgeOldData(int $logDays = 30, int $alertDays = 90): array + { + $deletedLogs = $this->db->pdo()->prepare( + "DELETE FROM log_entries WHERE created_at < datetime('now', ?)" + )->execute(['-' . $logDays . ' days'])->rowCount(); + + $deletedAlerts = $this->db->pdo()->prepare( + "DELETE FROM alerts WHERE status = 'resolved' AND created_at < datetime('now', ?)" + )->execute(['-' . $alertDays . ' days'])->rowCount(); + + $this->db->pdo()->exec("DELETE FROM rate_limiter WHERE window_start < " . (time() - 86400)); + + return ['log_entries_deleted' => $deletedLogs, 'alerts_deleted' => $deletedAlerts]; + } + + // --- Log Context --- + + public function getLogContext(int $id, int $before = 5, int $after = 5): array + { + $beforeStmt = $this->db->pdo()->prepare( + "SELECT * FROM log_entries WHERE id < ? ORDER BY id DESC LIMIT ?" + ); + $beforeStmt->execute([$id, $before]); + $beforeRows = array_reverse($beforeStmt->fetchAll()); + + $current = $this->db->pdo()->prepare("SELECT * FROM log_entries WHERE id = ?"); + $current->execute([$id]); + $currentRow = $current->fetch(); + + $afterStmt = $this->db->pdo()->prepare( + "SELECT * FROM log_entries WHERE id > ? ORDER BY id ASC LIMIT ?" + ); + $afterStmt->execute([$id, $after]); + $afterRows = $afterStmt->fetchAll(); + + return [ + 'before' => $beforeRows, + 'current' => $currentRow, + 'after' => $afterRows, + ]; + } + + // --- Bulk Operations --- + + public function bulkUpdateAlertStatus(array $ids, AlertStatus $status): int + { + if (empty($ids)) return 0; + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $this->db->pdo()->prepare( + "UPDATE alerts SET status = ? WHERE id IN ($placeholders)" + ); + $stmt->execute(array_merge([$status->value], $ids)); + return $stmt->rowCount(); + } + + public function exportAlerts(array $ids): array + { + if (empty($ids)) return []; + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $this->db->pdo()->prepare("SELECT * FROM alerts WHERE id IN ($placeholders) ORDER BY id"); + $stmt->execute($ids); + return array_map(fn(array $r) => Alert::fromRow($r), $stmt->fetchAll()); + } + + public function exportLogs(array $ids): array + { + if (empty($ids)) return []; + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $this->db->pdo()->prepare("SELECT * FROM log_entries WHERE id IN ($placeholders) ORDER BY id"); + $stmt->execute($ids); + return $stmt->fetchAll(); + } } \ No newline at end of file diff --git a/src/Worker/Orchestrator.php b/src/Worker/Orchestrator.php index 1c70f16..2e3a20e 100644 --- a/src/Worker/Orchestrator.php +++ b/src/Worker/Orchestrator.php @@ -16,6 +16,7 @@ class Orchestrator private TelegramNotifier $telegram; private array $sourceMap = []; private bool $running = true; + private int $lastCleanup = 0; public function __construct(Repository $repo, Engine $engine) { @@ -47,8 +48,20 @@ class Orchestrator pcntl_signal_dispatch(); $this->fileWatcher->tick(); $this->socketListener->tick(); + + if (time() - $this->lastCleanup > 3600) { + $this->lastCleanup = time(); + try { + $result = $this->repo->purgeOldData(); + fprintf(STDERR, "Retention cleanup: %d log entries, %d alerts purged\n", + $result['log_entries_deleted'], $result['alerts_deleted']); + } catch (\Throwable $e) { + fprintf(STDERR, "Retention cleanup error: %s\n", $e->getMessage()); + } + } } + fprintf(STDERR, "Worker shutting down gracefully...\n"); $this->stop(); }