initial commit
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Jakach Logging</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { font-size: .875rem; }
|
||||
.sidebar { position: fixed; top: 0; left: 0; bottom: 0; width: 220px; padding-top: 56px; z-index: 100; border-right: 1px solid var(--bs-border-color); }
|
||||
.sidebar .nav-link { color: var(--bs-secondary-color); padding: .5rem 1rem; border-radius: 0; }
|
||||
.sidebar .nav-link:hover, .sidebar .nav-link.active { color: var(--bs-body-color); background: var(--bs-tertiary-bg); }
|
||||
.sidebar .nav-link i { margin-right: .5rem; }
|
||||
.main { margin-left: 220px; padding-top: 56px; }
|
||||
.navbar-brand i { margin-right: .5rem; }
|
||||
.stat-card { border-left: 3px solid var(--bs-border-color); }
|
||||
.stat-card.critical { border-left-color: var(--bs-danger); }
|
||||
.stat-card.warning { border-left-color: var(--bs-warning); }
|
||||
.stat-card.info { border-left-color: var(--bs-info); }
|
||||
.card-header .btn-sm { font-size: .75rem; }
|
||||
.page-section { display: none; }
|
||||
.page-section.active { display: block; }
|
||||
.badge-severity { text-transform: uppercase; font-size: .65rem; letter-spacing: .5px; }
|
||||
.alert-row { cursor: pointer; }
|
||||
.alert-row:hover { background: var(--bs-tertiary-bg); }
|
||||
.log-line { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: .8rem; max-width: 500px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty-state { text-align: center; padding: 4rem 1rem; color: var(--bs-secondary-color); }
|
||||
.empty-state i { font-size: 3rem; margin-bottom: 1rem; }
|
||||
.toast-container { z-index: 1060; }
|
||||
#detailModal .modal-body { max-height: 70vh; overflow-y: auto; }
|
||||
pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius: .375rem; font-size: .8rem; white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; }
|
||||
@media (max-width: 768px) { .sidebar { display: none; } .main { margin-left: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="#"><i class="bi bi-terminal-plus"></i>Jakach Logging</a>
|
||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||
<span class="badge bg-danger d-none" id="criticalBadge">0</span>
|
||||
<span class="badge bg-warning text-dark d-none" id="warningBadge">0</span>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="refreshBtn" title="Refresh"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
<div class="form-check form-switch ms-2">
|
||||
<input class="form-check-input" type="checkbox" id="autoRefresh" checked>
|
||||
<label class="form-check-label" for="autoRefresh" style="font-size:.8rem">Auto</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar">
|
||||
<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="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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="container-fluid p-3">
|
||||
|
||||
<!-- DASHBOARD -->
|
||||
<div class="page-section active" id="page-dashboard">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Dashboard</h5>
|
||||
<small class="text-secondary" id="lastUpdated"></small>
|
||||
</div>
|
||||
<div class="row g-3 mb-3" id="statCards"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-bell me-1"></i>Recent Alerts</span>
|
||||
<a href="#" data-page="alerts" class="btn btn-outline-secondary btn-sm">View all</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive"><table class="table table-sm table-borderless mb-0"><tbody id="dashboardAlerts"><tr><td colspan="4" class="empty-state"><i class="bi bi-check-circle"></i><p class="mb-0">No alerts yet</p></td></tr></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-pie-chart me-1"></i>Alert Distribution</div>
|
||||
<div class="card-body" id="chartContainer"><div class="empty-state"><i class="bi bi-bar-chart"></i><p class="mb-0">No data</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ALERTS -->
|
||||
<div class="page-section" id="page-alerts">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-bell me-2"></i>Alerts</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select form-select-sm" id="filterSeverity" style="width:auto">
|
||||
<option value="">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
<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>
|
||||
<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</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>
|
||||
<nav><ul class="pagination pagination-sm mb-0" id="alertsPagination"></ul></nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SOURCES -->
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RULES -->
|
||||
<div class="page-section" id="page-rules">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Alert Rules</h5>
|
||||
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#ruleModal"><i class="bi bi-plus-lg"></i> Add Rule</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>Pattern</th><th>Severity</th><th>Rate Limit</th><th>Status</th><th style="width:60px"></th>
|
||||
</tr></thead>
|
||||
<tbody id="rulesBody"><tr><td colspan="6" class="empty-state"><i class="bi bi-sliders"></i><p class="mb-0">No rules configured</p></td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS -->
|
||||
<div class="page-section" id="page-settings">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Settings</h5>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-1"></i>System Info</div>
|
||||
<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 Path</dt><dd class="col-sm-8"><code>/app/data/logging.db</code></dd>
|
||||
<dt class="col-sm-4">Worker</dt><dd class="col-sm-8"><code>php bin/consume</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-book me-1"></i>Quick Reference</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-1"><strong>File sources</strong> — path to a log file on the worker container</p>
|
||||
<p class="mb-1"><strong>TCP/UDP sources</strong> — <code>tcp://0.0.0.0:9514</code> or <code>udp://0.0.0.0:9514</code></p>
|
||||
<p class="mb-0"><strong>Rules</strong> — use PHP regex patterns, e.g. <code>/error/i</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Detail Modal -->
|
||||
<div class="modal fade" id="detailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5 class="modal-title">Alert Detail</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body" id="detailBody"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-success btn-sm" id="ackBtn"><i class="bi bi-check-circle"></i> Acknowledge</button>
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div></div></div>
|
||||
|
||||
<!-- Source Modal -->
|
||||
<div class="modal fade" id="sourceModal" tabindex="-1">
|
||||
<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-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">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-select" name="type" required>
|
||||
<option value="file">File</option>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
</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">
|
||||
</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"}'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Add Source</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div></div></div>
|
||||
|
||||
<!-- Rule Modal -->
|
||||
<div class="modal fade" id="ruleModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="ruleForm">
|
||||
<div class="modal-header"><h5 class="modal-title">Add Alert Rule</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. PHP Error">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Pattern <small class="text-secondary">(PHP regex)</small></label>
|
||||
<input type="text" class="form-control" name="pattern" required placeholder="/error/i">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Severity</label>
|
||||
<select class="form-select" name="severity" required>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning" selected>Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Rate Limit <small class="text-secondary">(seconds, empty = no limit)</small></label>
|
||||
<input type="number" class="form-control" name="rate_limit_seconds" placeholder="60">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Add Rule</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div></div></div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const API = window.location.origin;
|
||||
|
||||
let state = { alerts: [], sources: [], rules: [], counts: [], alertPage: 0, alertPageSize: 50 };
|
||||
let autoRefreshInterval = null;
|
||||
let currentAlertId = null;
|
||||
|
||||
// --- Navigation ---
|
||||
document.querySelectorAll('[data-page]').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
showPage(el.dataset.page);
|
||||
});
|
||||
});
|
||||
|
||||
function showPage(name) {
|
||||
document.querySelectorAll('.page-section').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('page-' + name).classList.add('active');
|
||||
document.querySelectorAll('.sidebar .nav-link').forEach(l => l.classList.remove('active'));
|
||||
document.querySelector(`.sidebar .nav-link[data-page="${name}"]`)?.classList.add('active');
|
||||
loadPage(name);
|
||||
}
|
||||
|
||||
function loadPage(name) {
|
||||
switch (name) {
|
||||
case 'dashboard': loadDashboard(); break;
|
||||
case 'alerts': loadAlerts(); break;
|
||||
case 'sources': loadSources(); break;
|
||||
case 'rules': loadRules(); break;
|
||||
case 'settings': loadSettings(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- API Helpers ---
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(API + path, {
|
||||
headers: { 'Accept': 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}) },
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function toast(msg, type = 'success') {
|
||||
const container = document.querySelector('.toast-container');
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast align-items-center text-bg-${type} border-0`;
|
||||
el.role = 'alert';
|
||||
el.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(el);
|
||||
const t = new bootstrap.Toast(el);
|
||||
t.show();
|
||||
el.addEventListener('hidden.bs.toast', () => el.remove());
|
||||
}
|
||||
|
||||
function severityBadge(s) {
|
||||
const map = { critical: 'danger', warning: 'warning', info: 'info' };
|
||||
return `<span class="badge badge-severity bg-${map[s] || 'secondary'}">${s}</span>`;
|
||||
}
|
||||
|
||||
function statusBadge(s) {
|
||||
const map = { open: 'danger', acknowledged: 'warning', resolved: 'success' };
|
||||
return `<span class="badge bg-${map[s] || 'secondary'}">${s}</span>`;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
const sec = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (sec < 5) return 'just now';
|
||||
if (sec < 60) return sec + 's ago';
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return min + 'm ago';
|
||||
const hrs = Math.floor(min / 60);
|
||||
if (hrs < 24) return hrs + 'h ago';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
// --- DASHBOARD ---
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const [countsRes, alertsRes] = await Promise.all([
|
||||
api('/alerts/counts'),
|
||||
api('/alerts?limit=10&severity=critical&status=open'),
|
||||
]);
|
||||
const counts = Array.isArray(countsRes) ? countsRes : (countsRes.data || []);
|
||||
const alerts = alertsRes.data || [];
|
||||
|
||||
const total = counts.reduce((s, c) => s + parseInt(c.count), 0);
|
||||
const critical = counts.filter(c => c.severity === 'critical').reduce((s, c) => s + parseInt(c.count), 0);
|
||||
const warning = counts.filter(c => c.severity === 'warning').reduce((s, c) => s + parseInt(c.count), 0);
|
||||
const open = counts.filter(c => c.status === 'open').reduce((s, c) => s + parseInt(c.count), 0);
|
||||
|
||||
document.getElementById('statCards').innerHTML = `
|
||||
<div class="col-md-3"><div class="card stat-card critical"><div class="card-body"><h6 class="card-subtitle text-secondary mb-1">Critical</h6><h3 class="mb-0 text-danger">${critical}</h3></div></div></div>
|
||||
<div class="col-md-3"><div class="card stat-card warning"><div class="card-body"><h6 class="card-subtitle text-secondary mb-1">Warnings</h6><h3 class="mb-0 text-warning">${warning}</h3></div></div></div>
|
||||
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><h6 class="card-subtitle text-secondary mb-1">Open</h6><h3 class="mb-0">${open}</h3></div></div></div>
|
||||
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><h6 class="card-subtitle text-secondary mb-1">Total</h6><h3 class="mb-0">${total}</h3></div></div></div>
|
||||
`;
|
||||
|
||||
document.getElementById('criticalBadge').textContent = critical;
|
||||
document.getElementById('criticalBadge').classList.toggle('d-none', critical === 0);
|
||||
document.getElementById('warningBadge').textContent = warning;
|
||||
document.getElementById('warningBadge').classList.toggle('d-none', warning === 0);
|
||||
|
||||
const tbody = document.getElementById('dashboardAlerts');
|
||||
if (!alerts.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><i class="bi bi-check-circle"></i><p class="mb-0">No critical alerts</p></td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = alerts.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
|
||||
<td>${severityBadge(a.severity)}</td>
|
||||
<td>${a.rule_name}</td>
|
||||
<td class="log-line">${esc(a.message)}</td>
|
||||
<td class="text-secondary" style="white-space:nowrap">${timeAgo(a.created_at)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// Mini bar chart using CSS
|
||||
const chartEl = document.getElementById('chartContainer');
|
||||
const severityCounts = { critical: 0, warning: 0, info: 0 };
|
||||
counts.forEach(c => { if (severityCounts[c.severity] !== undefined) severityCounts[c.severity] += parseInt(c.count); });
|
||||
const maxVal = Math.max(...Object.values(severityCounts), 1);
|
||||
chartEl.innerHTML = `<div class="d-flex align-items-end gap-2" style="height:120px">${Object.entries(severityCounts).map(([sev, cnt]) => {
|
||||
const color = { critical: 'danger', warning: 'warning', info: 'info' }[sev] || 'secondary';
|
||||
const pct = (cnt / maxVal) * 100;
|
||||
return `<div class="d-flex flex-column align-items-center flex-fill"><div class="bg-${color}" style="width:100%;height:${pct}%;min-height:4px;border-radius:4px 4px 0 0"></div><small class="mt-1">${sev}<br><strong>${cnt}</strong></small></div>`;
|
||||
}).join('')}</div>`;
|
||||
|
||||
document.getElementById('lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
} catch (e) { console.error('dashboard error', e); }
|
||||
}
|
||||
|
||||
// --- ALERTS ---
|
||||
async function loadAlerts() {
|
||||
const severity = document.getElementById('filterSeverity').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const params = new URLSearchParams({ limit: state.alertPageSize, offset: state.alertPage * state.alertPageSize });
|
||||
if (severity) params.set('severity', severity);
|
||||
if (status) params.set('status', status);
|
||||
|
||||
try {
|
||||
const res = await api('/alerts?' + params.toString());
|
||||
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>${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';
|
||||
} catch (e) { console.error('alerts error', e); }
|
||||
}
|
||||
|
||||
function showAlert(id) {
|
||||
const a = state.alerts.find(x => x.id === id);
|
||||
if (!a) return;
|
||||
currentAlertId = id;
|
||||
document.getElementById('detailBody').innerHTML = `
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">ID</dt><dd class="col-sm-9">#${a.id}</dd>
|
||||
<dt class="col-sm-3">Rule</dt><dd class="col-sm-9">${esc(a.rule_name)} (ID ${a.rule_id})</dd>
|
||||
<dt class="col-sm-3">Severity</dt><dd class="col-sm-9">${severityBadge(a.severity)}</dd>
|
||||
<dt class="col-sm-3">Status</dt><dd class="col-sm-9">${statusBadge(a.status)}</dd>
|
||||
<dt class="col-sm-3">Source</dt><dd class="col-sm-9">${esc(a.source_name || '—')}</dd>
|
||||
<dt class="col-sm-3">Created</dt><dd class="col-sm-9">${new Date(a.created_at).toLocaleString()}</dd>
|
||||
<dt class="col-sm-3">Message</dt><dd class="col-sm-9">${esc(a.message)}</dd>
|
||||
<dt class="col-sm-3">Raw Line</dt><dd class="col-sm-9"><pre class="raw-line">${esc(a.raw_line)}</pre></dd>
|
||||
</dl>`;
|
||||
document.getElementById('ackBtn').style.display = a.status === 'open' ? '' : 'none';
|
||||
new bootstrap.Modal(document.getElementById('detailModal')).show();
|
||||
}
|
||||
|
||||
document.getElementById('ackBtn').addEventListener('click', async () => {
|
||||
if (currentAlertId) await ackAlert(currentAlertId);
|
||||
bootstrap.Modal.getInstance(document.getElementById('detailModal')).hide();
|
||||
});
|
||||
|
||||
async function ackAlert(id) {
|
||||
try {
|
||||
await api(`/alerts/${id}/ack`, { method: 'POST' });
|
||||
toast('Alert #' + id + ' acknowledged');
|
||||
loadPage(document.querySelector('.sidebar .nav-link.active')?.dataset.page || 'dashboard');
|
||||
} catch (e) { toast('Failed to acknowledge', 'danger'); }
|
||||
}
|
||||
|
||||
document.getElementById('filterSeverity').addEventListener('change', () => { state.alertPage = 0; loadAlerts(); });
|
||||
document.getElementById('filterStatus').addEventListener('change', () => { state.alertPage = 0; loadAlerts(); });
|
||||
document.getElementById('refreshAlertsBtn').addEventListener('click', loadAlerts);
|
||||
|
||||
// --- SOURCES ---
|
||||
async function loadSources() {
|
||||
try {
|
||||
const res = await api('/sources');
|
||||
const sources = res.data || [];
|
||||
state.sources = sources;
|
||||
const tbody = document.getElementById('sourcesBody');
|
||||
if (!sources.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><i class="bi bi-database"></i><p class="mb-0">No sources configured</p></td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = sources.map(s => `<tr>
|
||||
<td><strong>${esc(s.name)}</strong></td>
|
||||
<td><span class="badge bg-secondary">${s.type}</span></td>
|
||||
<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>
|
||||
</tr>`).join('');
|
||||
}
|
||||
} catch (e) { console.error('sources error', e); }
|
||||
}
|
||||
|
||||
async function deleteSource(id) {
|
||||
if (!confirm('Delete source?')) return;
|
||||
try {
|
||||
await api('/sources/' + id, { method: 'DELETE' });
|
||||
toast('Source deleted');
|
||||
loadSources();
|
||||
} catch (e) { toast('Delete failed', 'danger'); }
|
||||
}
|
||||
|
||||
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 = {}; }
|
||||
try {
|
||||
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'); }
|
||||
});
|
||||
|
||||
// --- RULES ---
|
||||
async function loadRules() {
|
||||
try {
|
||||
const res = await api('/rules');
|
||||
const rules = res.data || [];
|
||||
state.rules = rules;
|
||||
const tbody = document.getElementById('rulesBody');
|
||||
if (!rules.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><i class="bi bi-sliders"></i><p class="mb-0">No rules configured</p></td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = rules.map(r => `<tr>
|
||||
<td><strong>${esc(r.name)}</strong></td>
|
||||
<td><code>${esc(r.pattern)}</code></td>
|
||||
<td>${severityBadge(r.severity)}</td>
|
||||
<td>${r.rate_limit_seconds ? r.rate_limit_seconds + 's' : '—'}</td>
|
||||
<td>${r.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="deleteRule(${r.id})"><i class="bi bi-trash"></i></button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
} catch (e) { console.error('rules error', e); }
|
||||
}
|
||||
|
||||
async function deleteRule(id) {
|
||||
if (!confirm('Delete rule?')) return;
|
||||
try {
|
||||
await api('/rules/' + id, { method: 'DELETE' });
|
||||
toast('Rule deleted');
|
||||
loadRules();
|
||||
} catch (e) { toast('Delete failed', 'danger'); }
|
||||
}
|
||||
|
||||
document.getElementById('ruleForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(e.target));
|
||||
if (data.rate_limit_seconds) data.rate_limit_seconds = parseInt(data.rate_limit_seconds);
|
||||
else data.rate_limit_seconds = null;
|
||||
try {
|
||||
await api('/rules', { method: 'POST', body: JSON.stringify(data) });
|
||||
toast('Rule added');
|
||||
bootstrap.Modal.getInstance(document.getElementById('ruleModal')).hide();
|
||||
e.target.reset();
|
||||
loadRules();
|
||||
} catch (err) { toast('Failed to add rule', 'danger'); }
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
await api('/health');
|
||||
document.getElementById('sysHealth').textContent = 'Healthy';
|
||||
document.getElementById('sysHealth').className = 'badge bg-success';
|
||||
} catch {
|
||||
document.getElementById('sysHealth').textContent = 'Unreachable';
|
||||
document.getElementById('sysHealth').className = 'badge bg-danger';
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// --- Auto-refresh ---
|
||||
document.getElementById('autoRefresh').addEventListener('change', () => {
|
||||
if (document.getElementById('autoRefresh').checked) startAutoRefresh();
|
||||
else stopAutoRefresh();
|
||||
});
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
const active = document.querySelector('.page-section.active');
|
||||
if (active) loadPage(active.id.replace('page-', ''));
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshInterval) clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
}
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', () => {
|
||||
const active = document.querySelector('.page-section.active');
|
||||
if (active) loadPage(active.id.replace('page-', ''));
|
||||
});
|
||||
|
||||
// --- Bootstrap ---
|
||||
startAutoRefresh();
|
||||
loadDashboard();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use Jakach\Logging\Api\Router;
|
||||
|
||||
$router = new Router();
|
||||
$router->handle();
|
||||
Reference in New Issue
Block a user