@@ -455,6 +488,7 @@ function loadPage(name) {
switch (name) {
case 'dashboard': loadDashboard(); break;
case 'alerts': loadAlerts(); break;
+ case 'logs': break;
case 'sources': loadSources(); break;
case 'rules': loadRules(); break;
case 'settings': loadSettings(); break;
@@ -772,6 +806,38 @@ document.getElementById('saveTokensBtn').addEventListener('click', async () => {
}
});
+// --- LOGS ---
+async function loadLogs(query) {
+ if (!query) {
+ query = document.getElementById('logSearchInput').value.trim();
+ if (!query) {
+ document.getElementById('logsBody').innerHTML = '
Enter a search query above |
';
+ document.getElementById('logsCount').textContent = '';
+ return;
+ }
+ }
+
+ try {
+ const res = await api('/logs/search?q=' + encodeURIComponent(query) + '&limit=200');
+ 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 => `
+ | #${e.id} |
+ ${new Date(e.created_at).toLocaleString()} |
+ ${esc(e.line)} |
+ ${e.source_name ? '' + esc(e.source_name) + '' : '—'} |
+
`).join('');
+ }
+ document.getElementById('logsCount').textContent = entries.length + ' results';
+ } catch (e) { console.error('logs error', e); }
+}
+
+document.getElementById('logSearchBtn').addEventListener('click', () => loadLogs());
+document.getElementById('logSearchInput').addEventListener('keydown', e => { if (e.key === 'Enter') loadLogs(); });
+
// --- Helpers ---
function esc(s) {
const d = document.createElement('div');
diff --git a/src/Api/Router.php b/src/Api/Router.php
index ca74c01..7f77481 100644
--- a/src/Api/Router.php
+++ b/src/Api/Router.php
@@ -73,6 +73,8 @@ class Router
preg_match('#^/alerts/counts$#', $path) && $method === 'GET'
=> $this->repo->getAlertCounts(),
+ $path === '/logs/search' && $method === 'GET' => $this->searchLogs(),
+
$path === '/config/allowed_tokens' && $method === 'GET'
=> ['tokens' => $this->repo->getAllowedUserTokens()],
$path === '/config/allowed_tokens' && $method === 'PUT'
@@ -204,6 +206,17 @@ class Router
return $this->repo->searchAlerts($query, $limit);
}
+ private function searchLogs(): array
+ {
+ $query = $_GET['q'] ?? '';
+ if (empty($query)) {
+ return ['data' => []];
+ }
+ $limit = (int) ($_GET['limit'] ?? 200);
+ $offset = (int) ($_GET['offset'] ?? 0);
+ return ['data' => $this->repo->searchLogEntries($query, $limit, $offset)];
+ }
+
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 d20de36..c2b127f 100644
--- a/src/Storage/Database.php
+++ b/src/Storage/Database.php
@@ -116,25 +116,45 @@ class Database
END;
");
- $this->pdo->exec("
+$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,
- window_start INTEGER NOT NULL,
- count INTEGER DEFAULT 0,
- PRIMARY KEY (rule_id, window_start)
+ CREATE TABLE IF NOT EXISTS log_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ line TEXT NOT NULL,
+ source_id INTEGER,
+ source_name TEXT,
+ level TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
)
");
$this->pdo->exec("
- CREATE TABLE IF NOT EXISTS config (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
+ CREATE INDEX IF NOT EXISTS idx_log_entries_created ON log_entries(created_at DESC)
+ ");
+
+ $this->pdo->exec("
+ CREATE VIRTUAL TABLE IF NOT EXISTS log_entries_fts USING fts5(
+ line, source_name,
+ content='log_entries',
+ content_rowid='id',
+ tokenize='porter unicode61'
)
");
+
+ $this->pdo->exec("
+ CREATE TRIGGER IF NOT EXISTS log_entries_ai AFTER INSERT ON log_entries BEGIN
+ INSERT INTO log_entries_fts(rowid, line, source_name)
+ VALUES (new.id, new.line, new.source_name);
+ END;
+ ");
+
+ $this->pdo->exec("
+ INSERT OR IGNORE INTO log_entries_fts(rowid, line, source_name)
+ SELECT id, line, source_name FROM log_entries
+ ");
}
}
\ No newline at end of file
diff --git a/src/Storage/Repository.php b/src/Storage/Repository.php
index 2816e91..ac34313 100644
--- a/src/Storage/Repository.php
+++ b/src/Storage/Repository.php
@@ -159,6 +159,29 @@ class Repository
return array_map(fn(array $r) => Alert::fromRow($r), $rows);
}
+ // --- Log Entries ---
+
+ public function storeLogEntry(string $line, ?int $sourceId = null, ?string $sourceName = null, ?string $level = null): void
+ {
+ $stmt = $this->db->pdo()->prepare(
+ "INSERT INTO log_entries (line, source_id, source_name, level) VALUES (?, ?, ?, ?)"
+ );
+ $stmt->execute([$line, $sourceId, $sourceName, $level]);
+ }
+
+ public function searchLogEntries(string $query, int $limit = 200, int $offset = 0): array
+ {
+ $stmt = $this->db->pdo()->prepare(
+ "SELECT e.* FROM log_entries e
+ JOIN log_entries_fts fts ON e.id = fts.rowid
+ WHERE log_entries_fts MATCH ?
+ ORDER BY rank
+ LIMIT ? OFFSET ?"
+ );
+ $stmt->execute([$query, $limit, $offset]);
+ return $stmt->fetchAll();
+ }
+
// --- Config ---
public function getAllowedUserTokens(): array
diff --git a/src/Worker/Orchestrator.php b/src/Worker/Orchestrator.php
index e0220f7..4536049 100644
--- a/src/Worker/Orchestrator.php
+++ b/src/Worker/Orchestrator.php
@@ -59,6 +59,9 @@ class Orchestrator
private function handleLine(string $line, int $sourceId): void
{
$source = $this->sourceMap[$sourceId] ?? null;
+
+ $this->repo->storeLogEntry($line, $sourceId, $source?->name);
+
$alert = $this->engine->evaluate($line, $source);
if ($alert !== null) {