+40
-6
@@ -130,15 +130,37 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm" id="filterStatus" style="width:auto">
|
||||
<select class="form-select form-select-sm" id="filterStatus" style="width:auto">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="acknowledged">Acknowledged</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
</select>
|
||||
<div class="input-group input-group-sm" style="width:220px">
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Search logs..." maxlength="100">
|
||||
<button class="btn btn-outline-secondary" id="searchBtn"><i class="bi bi-search"></i></button>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="refreshAlertsBtn"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-dark"><tr>
|
||||
<th style="width:60px">ID</th>
|
||||
<th style="width:90px">Severity</th>
|
||||
<th style="width:100px">Status</th>
|
||||
<th>Message / Raw Line</th>
|
||||
<th>Source</th>
|
||||
<th style="width:170px">Created</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr></thead>
|
||||
<tbody id="alertsBody"><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></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
@@ -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() {
|
||||
<td>${a.status === 'open' ? `<button class="btn btn-outline-success btn-sm py-0" onclick="event.stopPropagation();ackAlert(${a.id})"><i class="bi bi-check"></i></button>` : ''}</td>
|
||||
</tr>`).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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user