@@ -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