diff --git a/public/index.html b/public/index.html index 6bcb184..4501a76 100644 --- a/public/index.html +++ b/public/index.html @@ -130,15 +130,37 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius - +
+ + +
+
+
+
+ + + + + + + + + + + +
IDSeverityStatusMessage / Raw LineSourceCreated

No alerts match those filters

+
+
+
@@ -545,12 +567,20 @@ async function loadDashboard() { async function loadAlerts() { const severity = document.getElementById('filterSeverity').value; const status = document.getElementById('filterStatus').value; - const params = new URLSearchParams({ limit: 100, offset: 0 }); - if (severity) params.set('severity', severity); - if (status) params.set('status', status); + const query = document.getElementById('searchInput').value.trim(); + + let url; + if (query) { + url = '/alerts/search?q=' + encodeURIComponent(query) + '&limit=100'; + } else { + const params = new URLSearchParams({ limit: 100, offset: 0 }); + if (severity) params.set('severity', severity); + if (status) params.set('status', status); + url = '/alerts?' + params.toString(); + } try { - const res = await api('/alerts?' + params.toString()); + const res = await api(url); const alerts = res.data || []; state.alerts = alerts; @@ -568,10 +598,14 @@ async function loadAlerts() { ${a.status === 'open' ? `` : ''} `).join(''); } - document.getElementById('alertsCount').textContent = alerts.length + ' alerts'; + const label = query ? 'search results' : 'alerts'; + document.getElementById('alertsCount').textContent = alerts.length + ' ' + label; } catch (e) { console.error('alerts error', e); } } +document.getElementById('searchBtn').addEventListener('click', loadAlerts); +document.getElementById('searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') loadAlerts(); }); + function showAlert(id) { const a = state.alerts.find(x => x.id === id); if (!a) return; diff --git a/src/Api/Router.php b/src/Api/Router.php index 9e20069..ca74c01 100644 --- a/src/Api/Router.php +++ b/src/Api/Router.php @@ -67,6 +67,7 @@ class Router => $this->deleteEntity('rule', (int) $m[1]), $path === '/alerts' && $method === 'GET' => $this->getAlerts(), + $path === '/alerts/search' && $method === 'GET' => $this->searchAlerts(), preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST' => $this->ackAlert((int) $m[1]), preg_match('#^/alerts/counts$#', $path) && $method === 'GET' @@ -193,6 +194,16 @@ class Router return ['status' => 'acknowledged', 'id' => $id]; } + private function searchAlerts(): array + { + $query = $_GET['q'] ?? ''; + if (empty($query)) { + return []; + } + $limit = (int) ($_GET['limit'] ?? 100); + return $this->repo->searchAlerts($query, $limit); + } + private function updateAllowedTokens(): array { $body = json_decode(file_get_contents('php://input'), true); diff --git a/src/Storage/Database.php b/src/Storage/Database.php index 5feb80a..d20de36 100644 --- a/src/Storage/Database.php +++ b/src/Storage/Database.php @@ -80,6 +80,47 @@ class Database CREATE INDEX IF NOT EXISTS idx_alerts_created ON alerts(created_at) "); + $this->pdo->exec(" + CREATE INDEX IF NOT EXISTS idx_alerts_severity ON alerts(severity) + "); + + $this->pdo->exec(" + CREATE VIRTUAL TABLE IF NOT EXISTS alerts_fts USING fts5( + message, raw_line, rule_name, source_name, + content='alerts', + content_rowid='id', + tokenize='porter unicode61' + ) + "); + + $this->pdo->exec(" + CREATE TRIGGER IF NOT EXISTS alerts_ai AFTER INSERT ON alerts BEGIN + INSERT INTO alerts_fts(rowid, message, raw_line, rule_name, source_name) + VALUES (new.id, new.message, new.raw_line, new.rule_name, new.source_name); + END; + "); + + $this->pdo->exec(" + CREATE TRIGGER IF NOT EXISTS alerts_ad AFTER DELETE ON alerts BEGIN + INSERT INTO alerts_fts(alerts_fts, rowid, message, raw_line, rule_name, source_name) + VALUES ('delete', old.id, old.message, old.raw_line, old.rule_name, old.source_name); + END; + "); + + $this->pdo->exec(" + CREATE TRIGGER IF NOT EXISTS alerts_au AFTER UPDATE ON alerts BEGIN + INSERT INTO alerts_fts(alerts_fts, rowid, message, raw_line, rule_name, source_name) + VALUES ('delete', old.id, old.message, old.raw_line, old.rule_name, old.source_name); + INSERT INTO alerts_fts(rowid, message, raw_line, rule_name, source_name) + VALUES (new.id, new.message, new.raw_line, new.rule_name, new.source_name); + END; + "); + + $this->pdo->exec(" + INSERT OR IGNORE INTO alerts_fts(rowid, message, raw_line, rule_name, source_name) + SELECT id, message, raw_line, rule_name, source_name FROM alerts + "); + $this->pdo->exec(" CREATE TABLE IF NOT EXISTS rate_limiter ( rule_id INTEGER NOT NULL, diff --git a/src/Storage/Repository.php b/src/Storage/Repository.php index 77d1f57..2816e91 100644 --- a/src/Storage/Repository.php +++ b/src/Storage/Repository.php @@ -145,6 +145,20 @@ class Repository )->fetchAll(); } + public function searchAlerts(string $query, int $limit = 100): array + { + $stmt = $this->db->pdo()->prepare( + "SELECT a.* FROM alerts a + JOIN alerts_fts fts ON a.id = fts.rowid + WHERE alerts_fts MATCH ? + ORDER BY rank + LIMIT ?" + ); + $stmt->execute([$query, $limit]); + $rows = $stmt->fetchAll(); + return array_map(fn(array $r) => Alert::fromRow($r), $rows); + } + // --- Config --- public function getAllowedUserTokens(): array