adding new features
Deploy / deploy (push) Successful in 13s

This commit is contained in:
2026-05-16 12:35:01 +02:00
parent fa559ba4be
commit 0c3b75d7a8
5 changed files with 623 additions and 28 deletions
+275 -9
View File
@@ -152,12 +152,19 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
<button class="btn btn-outline-secondary btn-sm" id="refreshAlertsBtn"><i class="bi bi-arrow-clockwise"></i></button>
</div>
</div>
<div class="d-flex gap-2 align-items-center mb-2" id="bulkBar" style="display:none">
<small class="text-secondary"><span id="selectedCount">0</span> selected</small>
<button class="btn btn-outline-success btn-sm" onclick="bulkAction('acknowledged')"><i class="bi bi-check"></i> Acknowledge</button>
<button class="btn btn-outline-secondary btn-sm" onclick="bulkAction('resolved')"><i class="bi bi-check-all"></i> Resolve</button>
<button class="btn btn-outline-info btn-sm" onclick="exportAlerts('json')"><i class="bi bi-download"></i> Export JSON</button>
<button class="btn btn-outline-info btn-sm" onclick="exportAlerts('csv')"><i class="bi bi-filetype-csv"></i> Export CSV</button>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0" id="alertsTable">
<thead class="table-dark"><tr>
<th style="width:60px;cursor:pointer" onclick="sortAlerts('id')">ID <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
<th style="width:80px;cursor:pointer" onclick="sortAlerts('id')">ID <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
<th style="width:90px;cursor:pointer" onclick="sortAlerts('severity')">Severity <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
<th style="width:100px;cursor:pointer" onclick="sortAlerts('status')">Status <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
<th>Message</th>
@@ -180,6 +187,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-search me-2"></i>Log Search</h5>
<small class="text-secondary" id="logsCount"></small>
<button class="btn btn-outline-info btn-sm" onclick="exportLogs()"><i class="bi bi-download"></i> Export</button>
</div>
<div class="card mb-3">
<div class="card-body py-3">
@@ -187,7 +195,11 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
<input type="text" class="form-control" id="logSearchInput" placeholder="Search all log lines..." maxlength="200">
<button class="btn btn-primary" id="logSearchBtn"><i class="bi bi-search me-1"></i>Search</button>
</div>
<small class="text-secondary mt-1 d-block">Use <code>*</code> as wildcard. <code>bla</code> matches "blabla". <code>*</code> shows all logs. Separate terms match all (AND).</small>
<div class="d-flex gap-2 mt-2 align-items-center">
<small class="text-secondary">Use <code>*</code> as wildcard. <code>bla</code> matches "blabla". <code>*</code> shows all logs. Separate terms match all (AND).</small>
<input type="datetime-local" class="form-control form-control-sm" id="logSince" style="width:auto" title="From">
<input type="datetime-local" class="form-control form-control-sm" id="logUntil" style="width:auto" title="To">
</div>
</div>
</div>
<div class="card">
@@ -259,6 +271,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Health</dt><dd class="col-sm-8"><span id="sysHealth" class="badge bg-secondary">checking...</span></dd>
<dt class="col-sm-4">DB Size</dt><dd class="col-sm-8"><span id="sysDbSize"></span></dd>
<dt class="col-sm-4">Auth Server</dt><dd class="col-sm-8"><a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></dd>
<dt class="col-sm-4">Logged in as</dt><dd class="col-sm-8" id="settingsUser"></dd>
<dt class="col-sm-4">DB Path</dt><dd class="col-sm-8"><code>/app/data/logging.db</code></dd>
@@ -274,6 +287,34 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
<p class="mb-0"><strong>Rules</strong> — PHP regex patterns, e.g. <code>/error/i</code></p>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><i class="bi bi-clock-history me-1"></i>Data Retention</div>
<div class="card-body">
<p class="small text-secondary">Auto-purge old data to keep the database small.</p>
<div class="row g-2 mb-2">
<div class="col">
<label class="form-label">Keep logs (days)</label>
<input type="number" class="form-control form-control-sm" id="retentionLogDays" value="30">
</div>
<div class="col">
<label class="form-label">Keep resolved alerts (days)</label>
<input type="number" class="form-control form-control-sm" id="retentionAlertDays" value="90">
</div>
</div>
<button class="btn btn-outline-danger btn-sm" id="runRetentionBtn"><i class="bi bi-trash3"></i> Purge Now</button>
<small id="retentionResult" class="ms-2"></small>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><i class="bi bi-journal-text me-1"></i>Audit Log</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height:300px;overflow-y:auto">
<table class="table table-sm mb-0">
<tbody id="auditLogBody"><tr><td class="text-secondary small text-center">Loading...</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-3">
@@ -450,6 +491,29 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
</form>
</div></div></div>
<!-- Rule Test Modal -->
<div class="modal fade" id="ruleTestModal" tabindex="-1" style="z-index:10001">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Test Rule Matching</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Log Line</label>
<textarea class="form-control font-monospace" id="ruleTestInput" rows="3" placeholder="Paste a log line to test..."></textarea>
</div>
<button class="btn btn-primary btn-sm" id="ruleTestBtn"><i class="bi bi-play"></i> Test</button>
</div>
<div class="modal-body" id="ruleTestResults" style="display:none"></div>
</div></div></div>
<!-- Log Context Modal -->
<div class="modal fade" id="contextModal" tabindex="-1" style="z-index:9999">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Log Context</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body" id="contextBody"><p class="text-secondary">Loading...</p></div>
</div></div></div>
<!-- Toast -->
<div class="toast-container position-fixed bottom-0 end-0 p-3"></div>
@@ -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 = '<div class="alert alert-success mb-0">No rules matched this line.</div>';
} else {
results.innerHTML = matches.map(m => `<div class="alert ${m.is_false_positive ? 'alert-warning' : 'alert-info'} mb-1 py-2 small">${m.is_false_positive ? '<i class="bi bi-x-circle me-1"></i>Blocked by false positive — ' : ''}<strong>${esc(m.rule_name)}</strong> → ${severityBadge(m.severity)}</div>`).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 = '<i class="bi bi-play-circle"></i> 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 = '<tr><td colspan="4" class="empty-state"><i class="bi bi-inbox"></i><p class="mb-0">No results for "' + esc(query) + '"</p></td></tr>';
} else {
tbody.innerHTML = entries.map(e => `<tr>
tbody.innerHTML = entries.map(e => `<tr style="cursor:pointer" onclick="showLogContext(${e.id})">
<td class="text-secondary" style="font-size:.75rem">#${e.id}</td>
<td class="text-secondary" style="white-space:nowrap;font-size:.8rem">${new Date(e.created_at).toLocaleString()}</td>
<td style="font-family:monospace;font-size:.8rem;max-width:600px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(e.line)}</td>
@@ -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 += `<div class="small mb-1" style="font-family:monospace;opacity:.6">#${l.id} <span class="text-secondary">${new Date(l.created_at).toLocaleString()}</span> ${esc(l.line)}</div>`;
});
if (res.current) {
html += `<div class="small mb-1 p-1" style="font-family:monospace;background:var(--bs-primary-bg-subtle);border-left:3px solid var(--bs-primary)">#${res.current.id} <span class="text-secondary">${new Date(res.current.created_at).toLocaleString()}</span> ${esc(res.current.line)}</div>`;
}
if (res.after) res.after.forEach(l => {
html += `<div class="small mb-1" style="font-family:monospace;opacity:.6">#${l.id} <span class="text-secondary">${new Date(l.created_at).toLocaleString()}</span> ${esc(l.line)}</div>`;
});
ctx.innerHTML = html || '<p class="text-secondary">No context found</p>';
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 = '<tr><td colspan="7" class="empty-state"><i class="bi bi-inbox"></i><p class="mb-0">No alerts match those filters</p></td></tr>';
} else {
tbody.innerHTML = sorted.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
<td class="text-secondary" onclick="event.stopPropagation()">
<input type="checkbox" class="form-check-input" ${selectedAlertIds.has(a.id) ? 'checked' : ''} onchange="toggleAlertSelection(${a.id})">
<span class="ms-1">#${a.id}</span>
</td>
<td>${severityBadge(a.severity)}</td>
<td>${statusBadge(a.status)}</td>
<td class="log-line">${esc(a.message)}</td>
<td>${esc(a.source_name || '—')}</td>
<td class="text-secondary" style="white-space:nowrap">${new Date(a.created_at).toLocaleString()}</td>
<td style="white-space:nowrap">
${a.status === 'open' ? `<button class="btn btn-outline-success btn-sm py-0 me-1" onclick="event.stopPropagation();quickAction(${a.id},'acknowledged')" title="Acknowledge"><i class="bi bi-check"></i></button>` : ''}
${a.status !== 'resolved' ? `<button class="btn btn-outline-secondary btn-sm py-0" onclick="event.stopPropagation();quickAction(${a.id},'resolved')" title="Resolve"><i class="bi bi-check-all"></i></button>` : ''}
</td>
</tr>`).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 = '<tr><td class="text-secondary small text-center">No audit entries</td></tr>';
} else {
tbody.innerHTML = entries.map(e => `<tr><td class="small"><span class="text-secondary">${new Date(e.created_at).toLocaleString()}</span> <strong>${esc(e.action)}</strong> ${esc(e.entity_type)}${e.entity_id ? ' #' + e.entity_id : ''}${e.username ? ' by ' + esc(e.username) : ''}${e.details ? '<br><small class="text-secondary">' + esc(e.details) + '</small>' : ''}</td></tr>`).join('');
}
} catch (e) { console.error('audit log error', e); }
}
// --- Helpers ---
function esc(s) {
+187 -6
View File
@@ -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)];
}
}
+12
View File
@@ -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'))
)
");
}
}
+136 -13
View File
@@ -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();
}
}
+13
View File
@@ -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();
}