@@ -82,6 +82,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link active" href="#" data-page="dashboard"><i class="bi bi-speedometer2"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#" data-page="alerts"><i class="bi bi-bell"></i>Alerts</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#" data-page="logs"><i class="bi bi-search"></i>Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#" data-page="sources"><i class="bi bi-database"></i>Sources</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#" data-page="rules"><i class="bi bi-sliders"></i>Rules</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#" data-page="settings"><i class="bi bi-gear"></i>Settings</a></li>
|
||||
@@ -184,6 +185,38 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOGS -->
|
||||
<div class="page-section" id="page-logs">
|
||||
<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>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="input-group">
|
||||
<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 FTS5 syntax: <code>error</code>, <code>"disk full"</code>, <code>error NOT warning</code>, <code>auth*</code></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark"><tr>
|
||||
<th style="width:60px">ID</th>
|
||||
<th style="width:170px">Time</th>
|
||||
<th>Line</th>
|
||||
<th>Source</th>
|
||||
</tr></thead>
|
||||
<tbody id="logsBody"><tr><td colspan="4" class="empty-state"><i class="bi bi-search"></i><p class="mb-0">Enter a search query above</p></td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SOURCES -->
|
||||
<div class="page-section" id="page-sources">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -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 = '<tr><td colspan="4" class="empty-state"><i class="bi bi-search"></i><p class="mb-0">Enter a search query above</p></td></tr>';
|
||||
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 = '<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>
|
||||
<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>
|
||||
<td>${e.source_name ? '<span class="badge bg-secondary">' + esc(e.source_name) + '</span>' : '—'}</td>
|
||||
</tr>`).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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user