+275
-9
@@ -152,12 +152,19 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<button class="btn btn-outline-secondary btn-sm" id="refreshAlertsBtn"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center mb-2" id="bulkBar" style="display:none">
|
||||
<small class="text-secondary"><span id="selectedCount">0</span> selected</small>
|
||||
<button class="btn btn-outline-success btn-sm" onclick="bulkAction('acknowledged')"><i class="bi bi-check"></i> Acknowledge</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="bulkAction('resolved')"><i class="bi bi-check-all"></i> Resolve</button>
|
||||
<button class="btn btn-outline-info btn-sm" onclick="exportAlerts('json')"><i class="bi bi-download"></i> Export JSON</button>
|
||||
<button class="btn btn-outline-info btn-sm" onclick="exportAlerts('csv')"><i class="bi bi-filetype-csv"></i> Export CSV</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0" id="alertsTable">
|
||||
<thead class="table-dark"><tr>
|
||||
<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:80px;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>
|
||||
@@ -180,6 +187,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<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>
|
||||
<button class="btn btn-outline-info btn-sm" onclick="exportLogs()"><i class="bi bi-download"></i> Export</button>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-3">
|
||||
@@ -187,7 +195,11 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<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 <code>*</code> as wildcard. <code>bla</code> matches "blabla". <code>*</code> shows all logs. Separate terms match all (AND).</small>
|
||||
<div class="d-flex gap-2 mt-2 align-items-center">
|
||||
<small class="text-secondary">Use <code>*</code> as wildcard. <code>bla</code> matches "blabla". <code>*</code> shows all logs. Separate terms match all (AND).</small>
|
||||
<input type="datetime-local" class="form-control form-control-sm" id="logSince" style="width:auto" title="From">
|
||||
<input type="datetime-local" class="form-control form-control-sm" id="logUntil" style="width:auto" title="To">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -259,6 +271,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Health</dt><dd class="col-sm-8"><span id="sysHealth" class="badge bg-secondary">checking...</span></dd>
|
||||
<dt class="col-sm-4">DB Size</dt><dd class="col-sm-8"><span id="sysDbSize">—</span></dd>
|
||||
<dt class="col-sm-4">Auth Server</dt><dd class="col-sm-8"><a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></dd>
|
||||
<dt class="col-sm-4">Logged in as</dt><dd class="col-sm-8" id="settingsUser">—</dd>
|
||||
<dt class="col-sm-4">DB Path</dt><dd class="col-sm-8"><code>/app/data/logging.db</code></dd>
|
||||
@@ -274,6 +287,34 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<p class="mb-0"><strong>Rules</strong> — PHP regex patterns, e.g. <code>/error/i</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><i class="bi bi-clock-history me-1"></i>Data Retention</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-secondary">Auto-purge old data to keep the database small.</p>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col">
|
||||
<label class="form-label">Keep logs (days)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="retentionLogDays" value="30">
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">Keep resolved alerts (days)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="retentionAlertDays" value="90">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm" id="runRetentionBtn"><i class="bi bi-trash3"></i> Purge Now</button>
|
||||
<small id="retentionResult" class="ms-2"></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><i class="bi bi-journal-text me-1"></i>Audit Log</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height:300px;overflow-y:auto">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody id="auditLogBody"><tr><td class="text-secondary small text-center">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
@@ -450,6 +491,29 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
</form>
|
||||
</div></div></div>
|
||||
|
||||
<!-- Rule Test Modal -->
|
||||
<div class="modal fade" id="ruleTestModal" tabindex="-1" style="z-index:10001">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5 class="modal-title">Test Rule Matching</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">Log Line</label>
|
||||
<textarea class="form-control font-monospace" id="ruleTestInput" rows="3" placeholder="Paste a log line to test..."></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="ruleTestBtn"><i class="bi bi-play"></i> Test</button>
|
||||
</div>
|
||||
<div class="modal-body" id="ruleTestResults" style="display:none"></div>
|
||||
</div></div></div>
|
||||
|
||||
<!-- Log Context Modal -->
|
||||
<div class="modal fade" id="contextModal" tabindex="-1" style="z-index:9999">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5 class="modal-title">Log Context</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body" id="contextBody"><p class="text-secondary">Loading...</p></div>
|
||||
</div></div></div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
||||
|
||||
@@ -961,9 +1025,12 @@ document.getElementById('ruleForm').addEventListener('submit', async e => {
|
||||
// --- SETTINGS ---
|
||||
async function loadSettings() {
|
||||
try {
|
||||
await api('/health');
|
||||
document.getElementById('sysHealth').textContent = 'Healthy';
|
||||
document.getElementById('sysHealth').className = 'badge bg-success';
|
||||
const health = await api('/health');
|
||||
document.getElementById('sysHealth').textContent = health.status === 'ok' ? 'Healthy' : 'Degraded';
|
||||
document.getElementById('sysHealth').className = 'badge bg-' + (health.status === 'ok' ? 'success' : 'warning');
|
||||
if (health.db_size) {
|
||||
document.getElementById('sysDbSize').textContent = health.db_size;
|
||||
}
|
||||
} catch {
|
||||
document.getElementById('sysHealth').textContent = 'Unreachable';
|
||||
document.getElementById('sysHealth').className = 'badge bg-danger';
|
||||
@@ -988,6 +1055,14 @@ async function loadSettings() {
|
||||
try {
|
||||
await loadFalsePositives();
|
||||
} catch (e) { console.error('load false positives error', e); }
|
||||
|
||||
try {
|
||||
const ret = await api('/system/retention');
|
||||
if (ret.log_days) document.getElementById('retentionLogDays').value = ret.log_days;
|
||||
if (ret.alert_days) document.getElementById('retentionAlertDays').value = ret.alert_days;
|
||||
} catch (e) { /* not critical */ }
|
||||
|
||||
loadAuditLog();
|
||||
}
|
||||
|
||||
document.getElementById('saveTokensBtn').addEventListener('click', async () => {
|
||||
@@ -1098,6 +1173,38 @@ document.getElementById('addFpBtn').addEventListener('click', async () => {
|
||||
} catch (e) { toast('Failed to add', 'danger'); }
|
||||
});
|
||||
|
||||
// --- RULE TEST ---
|
||||
document.getElementById('ruleTestBtn').addEventListener('click', async () => {
|
||||
const line = document.getElementById('ruleTestInput').value.trim();
|
||||
if (!line) { toast('Enter a log line', 'danger'); return; }
|
||||
try {
|
||||
const res = await api('/rules/test', { method: 'POST', body: JSON.stringify({ line }) });
|
||||
const matches = res.data || [];
|
||||
const results = document.getElementById('ruleTestResults');
|
||||
if (!matches.length) {
|
||||
results.innerHTML = '<div class="alert alert-success mb-0">No rules matched this line.</div>';
|
||||
} else {
|
||||
results.innerHTML = matches.map(m => `<div class="alert ${m.is_false_positive ? 'alert-warning' : 'alert-info'} mb-1 py-2 small">${m.is_false_positive ? '<i class="bi bi-x-circle me-1"></i>Blocked by false positive — ' : ''}<strong>${esc(m.rule_name)}</strong> → ${severityBadge(m.severity)}</div>`).join('');
|
||||
}
|
||||
results.style.display = 'block';
|
||||
} catch (e) { toast('Test failed', 'danger'); }
|
||||
});
|
||||
|
||||
document.querySelector('[data-page="rules"]').addEventListener('click', () => {
|
||||
setTimeout(() => {
|
||||
if (!document.getElementById('ruleTestModal')) return;
|
||||
const toolbar = document.querySelector('#page-rules .d-flex');
|
||||
if (toolbar && !toolbar.querySelector('#openRuleTestBtn')) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-outline-secondary btn-sm ms-2';
|
||||
btn.id = 'openRuleTestBtn';
|
||||
btn.innerHTML = '<i class="bi bi-play-circle"></i> Test Rule';
|
||||
btn.onclick = () => bootstrap.Modal.getOrCreateInstance(document.getElementById('ruleTestModal')).show();
|
||||
toolbar.querySelector('.btn-primary').after(btn);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// --- LOGS ---
|
||||
async function loadLogs(query) {
|
||||
if (!query) {
|
||||
@@ -1109,14 +1216,20 @@ async function loadLogs(query) {
|
||||
}
|
||||
}
|
||||
|
||||
let url = '/logs/search?q=' + encodeURIComponent(query) + '&limit=200';
|
||||
const since = document.getElementById('logSince').value;
|
||||
const until = document.getElementById('logUntil').value;
|
||||
if (since) url += '&since=' + encodeURIComponent(since);
|
||||
if (until) url += '&until=' + encodeURIComponent(until);
|
||||
|
||||
try {
|
||||
const res = await api('/logs/search?q=' + encodeURIComponent(query) + '&limit=200');
|
||||
const res = await api(url);
|
||||
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>
|
||||
tbody.innerHTML = entries.map(e => `<tr style="cursor:pointer" onclick="showLogContext(${e.id})">
|
||||
<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>
|
||||
@@ -1127,8 +1240,161 @@ async function loadLogs(query) {
|
||||
} catch (e) { console.error('logs error', e); }
|
||||
}
|
||||
|
||||
document.getElementById('logSearchBtn').addEventListener('click', () => loadLogs());
|
||||
document.getElementById('logSearchInput').addEventListener('keydown', e => { if (e.key === 'Enter') loadLogs(); });
|
||||
async function showLogContext(id) {
|
||||
try {
|
||||
const res = await api('/logs/context/' + id + '?before=5&after=5');
|
||||
const ctx = document.getElementById('contextBody');
|
||||
let html = '';
|
||||
if (res.before) res.before.forEach(l => {
|
||||
html += `<div class="small mb-1" style="font-family:monospace;opacity:.6">#${l.id} <span class="text-secondary">${new Date(l.created_at).toLocaleString()}</span> ${esc(l.line)}</div>`;
|
||||
});
|
||||
if (res.current) {
|
||||
html += `<div class="small mb-1 p-1" style="font-family:monospace;background:var(--bs-primary-bg-subtle);border-left:3px solid var(--bs-primary)">#${res.current.id} <span class="text-secondary">${new Date(res.current.created_at).toLocaleString()}</span> ${esc(res.current.line)}</div>`;
|
||||
}
|
||||
if (res.after) res.after.forEach(l => {
|
||||
html += `<div class="small mb-1" style="font-family:monospace;opacity:.6">#${l.id} <span class="text-secondary">${new Date(l.created_at).toLocaleString()}</span> ${esc(l.line)}</div>`;
|
||||
});
|
||||
ctx.innerHTML = html || '<p class="text-secondary">No context found</p>';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('contextModal')).show();
|
||||
} catch (e) { toast('Failed to load context', 'danger'); }
|
||||
}
|
||||
|
||||
async function exportLogs() {
|
||||
const query = document.getElementById('logSearchInput').value.trim();
|
||||
if (!query) { toast('Search something first', 'warning'); return; }
|
||||
const since = document.getElementById('logSince').value;
|
||||
const until = document.getElementById('logUntil').value;
|
||||
let url = '/logs/search?q=' + encodeURIComponent(query) + '&limit=10000';
|
||||
if (since) url += '&since=' + encodeURIComponent(since);
|
||||
if (until) url += '&until=' + encodeURIComponent(until);
|
||||
try {
|
||||
const res = await api(url);
|
||||
const entries = res.data || [];
|
||||
if (!entries.length) { toast('No results to export', 'warning'); return; }
|
||||
const csv = ['ID,Line,Source,Level,Created', ...entries.map(e =>
|
||||
[e.id, '"' + (e.line || '').replace(/"/g, '""') + '"',
|
||||
e.source_name || '', e.level || '', e.created_at].join(','))].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'logs.csv';
|
||||
a.click();
|
||||
toast('Exported ' + entries.length + ' rows');
|
||||
} catch (e) { toast('Export failed', 'danger'); }
|
||||
}
|
||||
|
||||
// --- BULK ALERT OPERATIONS ---
|
||||
let selectedAlertIds = new Set();
|
||||
|
||||
function toggleAlertSelection(id) {
|
||||
if (selectedAlertIds.has(id)) selectedAlertIds.delete(id);
|
||||
else selectedAlertIds.add(id);
|
||||
document.getElementById('bulkBar').classList.toggle('d-none', selectedAlertIds.size === 0);
|
||||
document.getElementById('selectedCount').textContent = selectedAlertIds.size;
|
||||
}
|
||||
|
||||
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" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="form-check-input" ${selectedAlertIds.has(a.id) ? 'checked' : ''} onchange="toggleAlertSelection(${a.id})">
|
||||
<span class="ms-1">#${a.id}</span>
|
||||
</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;
|
||||
document.getElementById('bulkBar').classList.toggle('d-none', selectedAlertIds.size === 0);
|
||||
}
|
||||
|
||||
async function bulkAction(status) {
|
||||
const ids = Array.from(selectedAlertIds);
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
await api('/alerts/bulk', { method: 'POST', body: JSON.stringify({ ids, status }) });
|
||||
toast(ids.length + ' alerts ' + status);
|
||||
selectedAlertIds.clear();
|
||||
loadAlerts();
|
||||
} catch (e) { toast('Bulk action failed', 'danger'); }
|
||||
}
|
||||
|
||||
async function exportAlerts(format) {
|
||||
const ids = Array.from(selectedAlertIds);
|
||||
if (!ids.length) { toast('Select alerts first', 'warning'); return; }
|
||||
try {
|
||||
const res = await api('/alerts/export', { method: 'POST', body: JSON.stringify({ ids, format }) });
|
||||
if (format === 'json') {
|
||||
const blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'alerts.json';
|
||||
a.click();
|
||||
}
|
||||
toast('Export done');
|
||||
} catch (e) { toast('Export failed', 'danger'); }
|
||||
}
|
||||
|
||||
// --- RETENTION ---
|
||||
document.getElementById('runRetentionBtn').addEventListener('click', async () => {
|
||||
const logDays = document.getElementById('retentionLogDays').value;
|
||||
const alertDays = document.getElementById('retentionAlertDays').value;
|
||||
const el = document.getElementById('retentionResult');
|
||||
el.textContent = 'Purging...';
|
||||
try {
|
||||
const res = await api('/system/retention', { method: 'POST', body: JSON.stringify({ log_days: parseInt(logDays), alert_days: parseInt(alertDays) }) });
|
||||
el.textContent = res.log_entries_deleted + ' logs, ' + res.alerts_deleted + ' alerts purged';
|
||||
el.className = 'ms-2 text-success';
|
||||
setTimeout(() => { el.textContent = ''; }, 5000);
|
||||
} catch (e) { el.textContent = 'Failed'; el.className = 'ms-2 text-danger'; }
|
||||
});
|
||||
|
||||
// --- AUDIT LOG ---
|
||||
async function loadAuditLog() {
|
||||
try {
|
||||
const res = await api('/system/audit-log');
|
||||
const entries = res.data || [];
|
||||
const tbody = document.getElementById('auditLogBody');
|
||||
if (!entries.length) {
|
||||
tbody.innerHTML = '<tr><td class="text-secondary small text-center">No audit entries</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = entries.map(e => `<tr><td class="small"><span class="text-secondary">${new Date(e.created_at).toLocaleString()}</span> <strong>${esc(e.action)}</strong> ${esc(e.entity_type)}${e.entity_id ? ' #' + e.entity_id : ''}${e.username ? ' by ' + esc(e.username) : ''}${e.details ? '<br><small class="text-secondary">' + esc(e.details) + '</small>' : ''}</td></tr>`).join('');
|
||||
}
|
||||
} catch (e) { console.error('audit log error', e); }
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
function esc(s) {
|
||||
|
||||
Reference in New Issue
Block a user