making sources editable, adding sorting in runles
Deploy / deploy (push) Successful in 8s

This commit is contained in:
2026-05-06 19:14:06 +02:00
parent 5b5fd78eb6
commit fae485c9cb
3 changed files with 140 additions and 60 deletions
+115 -60
View File
@@ -155,38 +155,20 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<table class="table table-hover table-sm mb-0" id="alertsTable">
<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>
<th style="width:60px;cursor:pointer" onclick="sortAlerts('id')">ID <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
<th style="width:90px;cursor:pointer" onclick="sortAlerts('severity')">Severity <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
<th style="width:100px;cursor:pointer" onclick="sortAlerts('status')">Status <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
<th>Message</th>
<th style="cursor:pointer" onclick="sortAlerts('source_name')">Source <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
<th style="width:170px;cursor:pointer" onclick="sortAlerts('created_at')">Created <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
<th style="width:100px"></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">
<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</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</p></td></tr></tbody>
</table>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center py-2">
<small class="text-secondary" id="alertsCount">0 alerts</small>
</div>
@@ -229,14 +211,14 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
<div class="page-section" id="page-sources">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-database me-2"></i>Log Sources</h5>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#sourceModal"><i class="bi bi-plus-lg"></i> Add Source</button>
<button class="btn btn-primary btn-sm" onclick="resetSourceForm();new bootstrap.Modal(document.getElementById('sourceModal')).show()"><i class="bi bi-plus-lg"></i> Add Source</button>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-dark"><tr>
<th>Name</th><th>Type</th><th>Address</th><th>Labels</th><th>Status</th><th style="width:60px"></th>
<th>Name</th><th>Type</th><th>Address</th><th>Labels</th><th>Status</th><th style="width:100px"></th>
</tr></thead>
<tbody id="sourcesBody"><tr><td colspan="6" class="empty-state"><i class="bi bi-database"></i><p class="mb-0">No sources configured</p></td></tr></tbody>
</table>
@@ -367,15 +349,18 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
<div class="modal-dialog">
<div class="modal-content">
<form id="sourceForm">
<div class="modal-header"><h5 class="modal-title">Add Log Source</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-header"><h5 class="modal-title" id="sourceModalLabel">Add Source</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" required placeholder="e.g. nginx-access">
<input type="hidden" name="id" id="sourceFormId" value="">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" id="sourceFormName" required placeholder="e.g. nginx-access">
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select class="form-select" name="type" required>
<select class="form-select" name="type" id="sourceFormType" required>
<option value="file">File</option>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
@@ -384,15 +369,19 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
</div>
<div class="mb-3">
<label class="form-label">Address</label>
<input type="text" class="form-control" name="address" required placeholder="/var/log/nginx/access.log or tcp://0.0.0.0:9514">
<input type="text" class="form-control" name="address" id="sourceFormAddress" required placeholder="/var/log/nginx/access.log or tcp://0.0.0.0:9514">
</div>
<div class="mb-3">
<label class="form-label">Labels <small class="text-secondary">(JSON)</small></label>
<input type="text" class="form-control" name="labels" placeholder='{"env":"prod"}'>
<input type="text" class="form-control" name="labels" id="sourceFormLabels" placeholder='{"env":"prod"}'>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="active" id="sourceFormActive" checked>
<label class="form-check-label" for="sourceFormActive">Active</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Add Source</button>
<button type="submit" class="btn btn-primary" id="sourceFormSubmit">Add Source</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
@@ -453,7 +442,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
<script>
const API = window.location.origin;
let state = { alerts: [], sources: [], rules: [], counts: [], user: null };
let state = { alerts: [], sources: [], rules: [], counts: [], user: null, sortField: 'created_at', sortDir: 'desc' };
let autoRefreshInterval = null;
let currentAlertId = null;
@@ -680,29 +669,64 @@ async function loadAlerts() {
const res = await api(url);
const alerts = res.data || [];
state.alerts = alerts;
const tbody = document.getElementById('alertsBody');
if (!alerts.length) {
tbody.innerHTML = '<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>';
} else {
tbody.innerHTML = alerts.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
<td class="text-secondary">#${a.id}</td>
<td>${severityBadge(a.severity)}</td>
<td>${statusBadge(a.status)}</td>
<td class="log-line">${esc(a.message)}</td>
<td>${esc(a.source_name || '—')}</td>
<td class="text-secondary" style="white-space:nowrap">${new Date(a.created_at).toLocaleString()}</td>
<td style="white-space:nowrap">
${a.status === 'open' ? `<button class="btn btn-outline-success btn-sm py-0 me-1" onclick="event.stopPropagation();quickAction(${a.id},'acknowledged')" title="Acknowledge"><i class="bi bi-check"></i></button>` : ''}
${a.status !== 'resolved' ? `<button class="btn btn-outline-secondary btn-sm py-0" onclick="event.stopPropagation();quickAction(${a.id},'resolved')" title="Resolve"><i class="bi bi-check-all"></i></button>` : ''}
</td>
</tr>`).join('');
}
const label = query ? 'search results' : 'alerts';
document.getElementById('alertsCount').textContent = alerts.length + ' ' + label;
renderAlerts(query);
} catch (e) { console.error('alerts error', e); }
}
function renderAlerts(query) {
let sorted = [...state.alerts];
const field = state.sortField;
const dir = state.sortDir;
sorted.sort((a, b) => {
let va = a[field], vb = b[field];
if (field === 'severity') {
const order = ['debug','info','notice','warning_low','warning','warning_high','error','critical_low','critical','critical_high','emergency'];
va = order.indexOf(va);
vb = order.indexOf(vb);
} else if (field === 'created_at') {
va = new Date(va).getTime();
vb = new Date(vb).getTime();
} else {
va = (va || '').toString().toLowerCase();
vb = (vb || '').toString().toLowerCase();
if (va < vb) return dir === 'asc' ? -1 : 1;
if (va > vb) return dir === 'asc' ? 1 : -1;
return 0;
}
return dir === 'asc' ? va - vb : vb - va;
});
const tbody = document.getElementById('alertsBody');
if (!sorted.length) {
tbody.innerHTML = '<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>';
} else {
tbody.innerHTML = sorted.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
<td class="text-secondary">#${a.id}</td>
<td>${severityBadge(a.severity)}</td>
<td>${statusBadge(a.status)}</td>
<td class="log-line">${esc(a.message)}</td>
<td>${esc(a.source_name || '—')}</td>
<td class="text-secondary" style="white-space:nowrap">${new Date(a.created_at).toLocaleString()}</td>
<td style="white-space:nowrap">
${a.status === 'open' ? `<button class="btn btn-outline-success btn-sm py-0 me-1" onclick="event.stopPropagation();quickAction(${a.id},'acknowledged')" title="Acknowledge"><i class="bi bi-check"></i></button>` : ''}
${a.status !== 'resolved' ? `<button class="btn btn-outline-secondary btn-sm py-0" onclick="event.stopPropagation();quickAction(${a.id},'resolved')" title="Resolve"><i class="bi bi-check-all"></i></button>` : ''}
</td>
</tr>`).join('');
}
const label = document.getElementById('searchInput').value.trim() ? 'search results' : 'alerts';
document.getElementById('alertsCount').textContent = sorted.length + ' ' + label;
}
function sortAlerts(field) {
if (state.sortField === field) {
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortField = field;
state.sortDir = 'asc';
}
renderAlerts();
}
document.getElementById('searchBtn').addEventListener('click', loadAlerts);
document.getElementById('searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') loadAlerts(); });
@@ -767,7 +791,7 @@ async function loadSources() {
<td><code>${esc(s.address)}</code></td>
<td><small>${s.labels && Object.keys(s.labels).length ? esc(JSON.stringify(s.labels)) : '—'}</small></td>
<td>${s.active ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
<td><button class="btn btn-outline-danger btn-sm py-0" onclick="deleteSource(${s.id})"><i class="bi bi-trash"></i></button></td>
<td><button class="btn btn-outline-primary btn-sm py-0 me-1" onclick="editSource(${s.id})"><i class="bi bi-pencil"></i></button><button class="btn btn-outline-danger btn-sm py-0" onclick="deleteSource(${s.id})"><i class="bi bi-trash"></i></button></td>
</tr>`).join('');
}
} catch (e) { console.error('sources error', e); }
@@ -782,18 +806,49 @@ async function deleteSource(id) {
} catch (e) { toast('Delete failed', 'danger'); }
}
function editSource(id) {
const s = state.sources.find(x => x.id === id);
if (!s) return;
document.getElementById('sourceModalLabel').textContent = 'Edit Source';
document.getElementById('sourceFormSubmit').textContent = 'Update Source';
document.getElementById('sourceFormId').value = s.id;
document.getElementById('sourceFormName').value = s.name;
document.getElementById('sourceFormType').value = s.type;
document.getElementById('sourceFormAddress').value = s.address;
document.getElementById('sourceFormLabels').value = s.labels && Object.keys(s.labels).length ? JSON.stringify(s.labels) : '';
document.getElementById('sourceFormActive').checked = s.active;
new bootstrap.Modal(document.getElementById('sourceModal')).show();
}
function resetSourceForm() {
document.getElementById('sourceModalLabel').textContent = 'Add Source';
document.getElementById('sourceFormSubmit').textContent = 'Add Source';
document.getElementById('sourceFormId').value = '';
document.getElementById('sourceForm').reset();
document.getElementById('sourceFormActive').checked = true;
}
document.getElementById('sourceModal').addEventListener('hidden.bs.modal', resetSourceForm);
document.getElementById('sourceForm').addEventListener('submit', async e => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
if (data.labels) { try { data.labels = JSON.parse(data.labels); } catch { data.labels = {}; } }
else { data.labels = {}; }
data.active = !!data.active;
const id = data.id;
delete data.id;
try {
await api('/sources', { method: 'POST', body: JSON.stringify(data) });
toast('Source added');
if (id) {
await api('/sources/' + id, { method: 'PUT', body: JSON.stringify(data) });
toast('Source updated');
} else {
await api('/sources', { method: 'POST', body: JSON.stringify(data) });
toast('Source added');
}
bootstrap.Modal.getInstance(document.getElementById('sourceModal')).hide();
e.target.reset();
loadSources();
} catch (err) { toast('Failed to add source', 'danger'); }
} catch (err) { toast('Failed to save source', 'danger'); }
});
// --- RULES ---
+16
View File
@@ -63,6 +63,8 @@ class Router
$path === '/sources' && $method === 'POST' => $this->createSource(),
preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'DELETE'
=> $this->deleteEntity('source', (int) $m[1]),
preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'PUT'
=> $this->updateSource((int) $m[1]),
$path === '/rules' && $method === 'GET' => $this->repo->getRules(),
$path === '/rules' && $method === 'POST' => $this->createRule(),
@@ -195,6 +197,20 @@ class Router
return ['status' => 'deleted', 'id' => $id];
}
private function updateSource(int $id): mixed
{
$body = json_decode(file_get_contents('php://input'), true);
$type = LogSourceType::from($body['type'] ?? '');
return $this->repo->updateSource(
id: $id,
name: $body['name'],
type: $type,
address: $body['address'],
labels: $body['labels'] ?? [],
active: $body['active'] ?? true,
);
}
private function updateRule(int $id): mixed
{
$body = json_decode(file_get_contents('php://input'), true);
+9
View File
@@ -46,6 +46,15 @@ class Repository
$this->db->pdo()->prepare("DELETE FROM log_sources WHERE id = ?")->execute([$id]);
}
public function updateSource(int $id, string $name, LogSourceType $type, string $address, array $labels = [], bool $active = true): LogSource
{
$stmt = $this->db->pdo()->prepare(
"UPDATE log_sources SET name = ?, type = ?, address = ?, labels = ?, active = ? WHERE id = ?"
);
$stmt->execute([$name, $type->value, $address, json_encode($labels), (int) $active, $id]);
return $this->getSource($id);
}
// --- Rules ---
public function getRules(): array