Menu
Public repository / feae09bd03462faf4e121831de837d98091d0749
AkurAI-Monitor / ui/app.js
Latest change feae09bd03462faf4e121831de837d98091d0749 - Add host-filtered dashboard sidebar by Ólafur Búi Ólafsson
const $ = (s) => document.querySelector(s);
const app = $('#app');
// View + live state
let rangeHours = 24;
let currentView = 'dashboard';
let currentSpecs = [];
let renderedBadgeKey = '';
let chartTimer = null;
let chartController = null;
let latestMetrics = [];
let dashboardHostId = null;
let dashboardCategory = 'All';
let dashboardHosts = null;
let liveLogs = [];
const LOG_CAP = 1000;
let logRangeHours = 1;
let logSearch = '';
let logExcluded = new Set();
let logExcludedCats = new Set();
let logLevel = 'all'; // 'all' | 'warn' | 'error'
let logSourceKey = '';
let connState = 'connecting'; // 'live' | 'polling' | 'connecting'
let receiverTarget = '';
async function manageApi(path, init) {
const response = await fetch(path, {
...init,
headers: { 'content-type': 'application/json', ...(init?.headers || {}) },
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || `Request failed (${response.status})`);
return body;
}
function showToast(message, kind = 'success') {
let tray = document.querySelector('.toast-tray');
if (!tray) {
tray = document.createElement('div');
tray.className = 'toast-tray';
tray.setAttribute('aria-live', 'polite');
document.body.appendChild(tray);
}
const toast = document.createElement('div');
toast.className = `toast ${kind}`;
toast.textContent = message;
tray.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
function confirmAction(title, message, actionLabel) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal-layer';
modal.innerHTML = `<div class="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<h3 id="confirm-title">${escapeHtml(title)}</h3>
<p>${escapeHtml(message)}</p>
<div class="modal-actions"><button class="quiet" data-cancel>Cancel</button><button class="danger" data-confirm>${escapeHtml(actionLabel)}</button></div>
</div>`;
document.body.appendChild(modal);
const finish = (answer) => { modal.remove(); resolve(answer); };
modal.querySelector('[data-cancel]').addEventListener('click', () => finish(false));
modal.querySelector('[data-confirm]').addEventListener('click', () => finish(true));
modal.addEventListener('click', (event) => { if (event.target === modal) finish(false); });
modal.querySelector('[data-cancel]').focus();
});
}
async function api(path, init) {
const res = await fetch(path, init);
return res.json();
}
// Host inventory is shared by the dashboard sidebar, cards, and chart legends.
let hostNames = new Map();
async function loadDashboardHosts() {
if (dashboardHosts) return dashboardHosts;
try {
const data = await manageApi('/api/manage/hosts');
dashboardHosts = (data.items || []).sort((a, b) => a.name.localeCompare(b.name));
hostNames = new Map(dashboardHosts.map((host) => [host.id, host.name]));
} catch {
dashboardHosts = [];
}
return dashboardHosts;
}
function hostName(hostId) {
if (hostId == null) return '—';
return hostNames.get(hostId) || `Host ${hostId}`;
}
function ts(epoch) {
return new Date(epoch * 1000).toLocaleString();
}
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#60a5fa';
}
function severity(name, value) {
if (name.includes('used_pct') || name === 'cpu.usage' || name === 'mem.used_pct') {
if (value >= 90) return 'crit';
if (value >= 75) return 'warn';
return 'ok';
}
return 'ok';
}
function formatValue(name, value) {
if (name.includes('_kb')) return (value / 1024 / 1024).toFixed(1) + ' GB';
if (name.includes('_gb')) return value.toFixed(1) + ' GB';
if (name.includes('_pct') || name === 'cpu.usage') return value.toFixed(1) + '%';
if (name.includes('bytes')) return formatBytes(value) + '/s';
if (name === 'uptime.seconds') return formatDuration(value);
if (name.startsWith('load.')) return value.toFixed(2);
if (name === 'dns.qps') return value.toFixed(2) + '/s';
if (name.startsWith('dns.latency_us.')) return formatMicros(value);
if (name.startsWith('dns.')) return value.toFixed(0);
return value.toFixed(1);
}
function formatMicros(us) {
if (us < 1000) return us.toFixed(0) + ' µs';
return (us / 1000).toFixed(1) + ' ms';
}
function formatBytes(b) {
if (b < 1024) return b.toFixed(0) + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
return (b / 1073741824).toFixed(1) + ' GB';
}
function formatDuration(secs) {
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
return d > 0 ? `${d}d ${h}h` : `${h}h`;
}
function metricLabel(name) {
const labels = {
'cpu.usage': 'CPU Usage',
'mem.used_pct': 'Memory',
'mem.total_kb': 'Total RAM',
'mem.used_kb': 'Used RAM',
'mem.available_kb': 'Available RAM',
'load.1m': 'Load 1m',
'load.5m': 'Load 5m',
'load.15m': 'Load 15m',
'uptime.seconds': 'Uptime',
'dns.qps': 'DNS Queries/s',
'dns.latency_us.p95': 'DNS Latency p95',
};
if (labels[name]) return labels[name];
if (name.startsWith('disk.')) return name.replace('disk.', 'Disk ').replace('.used_pct', ' Usage').replace('.total_gb', ' Total').replace('.used_gb', ' Used');
if (name.startsWith('net.')) return name.replace('net.', 'Net ').replace('.rx_bytes', ' RX').replace('.tx_bytes', ' TX');
return name;
}
function connLabel() {
if (connState === 'live') return '<span class="live-dot live"></span>Live';
if (connState === 'polling') return '<span class="live-dot"></span>Polling (10s)';
return '<span class="live-dot"></span>Connecting…';
}
function updateConnUI() {
document.querySelectorAll('.conn').forEach((el) => { el.innerHTML = connLabel(); });
}
// ── Dashboard ──────────────────────────────────────────────────────
const cardOrder = ['cpu.usage', 'mem.used_pct', 'load.1m', 'uptime.seconds'];
const RANGES = [[1, '1h'], [6, '6h'], [24, '24h'], [168, '7d']];
function sortMetrics(metrics) {
return [...metrics].sort((a, b) => {
const ai = cardOrder.indexOf(a.name);
const bi = cardOrder.indexOf(b.name);
if (ai >= 0 && bi >= 0) return ai - bi;
if (ai >= 0) return -1;
if (bi >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
function chartSpecs(names) {
const specs = [
{ category: 'System', title: 'CPU Usage', series: [{ name: 'cpu.usage', color: '--accent' }], min: 0, max: 100, fmt: (v) => v.toFixed(0) + '%' },
{ category: 'System', title: 'Memory', series: [{ name: 'mem.used_pct', color: '--ok' }], min: 0, max: 100, fmt: (v) => v.toFixed(0) + '%' },
{
category: 'System',
title: 'Load Average',
series: [
{ name: 'load.1m', color: '--accent' },
{ name: 'load.5m', color: '--warn' },
{ name: 'load.15m', color: '--muted' },
],
min: 0,
fmt: (v) => v.toFixed(1),
},
];
for (const n of names.filter((n) => n.startsWith('disk.') && n.endsWith('.used_pct')).sort()) {
specs.push({ category: 'Storage', title: metricLabel(n), series: [{ name: n, color: '--warn' }], min: 0, max: 100, fmt: (v) => v.toFixed(0) + '%' });
}
const ifaces = [...new Set(names.filter((n) => n.startsWith('net.')).map((n) => n.split('.')[1]))].sort();
for (const iface of ifaces) {
specs.push({
category: 'Network',
title: `Network ${iface}`,
series: [
{ name: `net.${iface}.rx_bytes`, color: '--ok', label: 'RX' },
{ name: `net.${iface}.tx_bytes`, color: '--accent', label: 'TX' },
],
min: 0,
fmt: (v) => formatBytes(v),
});
}
// DNS (akurai-dns) — only when its metrics are flowing.
if (names.some((n) => n.startsWith('dns.'))) {
specs.push({
category: 'DNS',
title: 'DNS Query Rate',
series: [{ name: 'dns.qps', color: '--accent', label: 'queries/s' }],
min: 0,
fmt: (v) => v.toFixed(1) + '/s',
});
specs.push({
category: 'DNS',
title: 'DNS Response Codes',
series: [
{ name: 'dns.rcode.noerror', color: '--ok', label: 'NOERROR' },
{ name: 'dns.rcode.nxdomain', color: '--warn', label: 'NXDOMAIN' },
{ name: 'dns.rcode.refused', color: '--danger', label: 'REFUSED' },
{ name: 'dns.rcode.servfail', color: '--muted', label: 'SERVFAIL' },
],
min: 0,
fmt: (v) => v.toFixed(0),
});
specs.push({
category: 'DNS',
title: 'DNS Query Types',
series: [
{ name: 'dns.qtype.a', color: '--accent', label: 'A' },
{ name: 'dns.qtype.aaaa', color: '--ok', label: 'AAAA' },
{ name: 'dns.qtype.mx', color: '--warn', label: 'MX' },
{ name: 'dns.qtype.txt', color: '--danger', label: 'TXT' },
{ name: 'dns.qtype.ptr', color: '--muted', label: 'PTR' },
],
min: 0,
fmt: (v) => v.toFixed(0),
});
specs.push({
category: 'DNS',
title: 'DNS Latency',
series: [
{ name: 'dns.latency_us.avg', color: '--ok', label: 'avg' },
{ name: 'dns.latency_us.p95', color: '--warn', label: 'p95' },
{ name: 'dns.latency_us.max', color: '--danger', label: 'max' },
],
min: 0,
fmt: (v) => (v < 1000 ? v.toFixed(0) + 'µs' : (v / 1000).toFixed(1) + 'ms'),
});
}
return specs;
}
// Render order for chart category sections; unknown categories follow, sorted.
const CATEGORY_ORDER = ['System', 'Storage', 'Network', 'DNS'];
function orderedCategories(specs) {
const present = [...new Set(specs.map((s) => s.category || 'Other'))];
return present.sort((a, b) => {
const ai = CATEGORY_ORDER.indexOf(a);
const bi = CATEGORY_ORDER.indexOf(b);
if (ai >= 0 && bi >= 0) return ai - bi;
if (ai >= 0) return -1;
if (bi >= 0) return 1;
return a.localeCompare(b);
});
}
// DNS emits ~23 series for charts; only these two are worth a status card —
// the rest live in the DNS chart section.
const DNS_CARDS = new Set(['dns.qps', 'dns.latency_us.p95']);
function cardVisible(name) {
return !name.startsWith('dns.') || DNS_CARDS.has(name);
}
const DASHBOARD_CATEGORIES = ['All', 'System', 'Storage', 'Network', 'DNS'];
function metricCategory(name) {
if (name.startsWith('disk.')) return 'Storage';
if (name.startsWith('net.')) return 'Network';
if (name.startsWith('dns.')) return 'DNS';
return 'System';
}
function visibleDashboardMetrics(metrics) {
return metrics.filter((metric) =>
(dashboardHostId == null || metric.host_id === dashboardHostId)
&& (dashboardCategory === 'All' || metricCategory(metric.name) === dashboardCategory)
);
}
function metricKey(metric) {
return `${metric.host_id ?? 'none'}:${metric.name}`;
}
function renderDashboardSidebar(metrics) {
const latestByHost = new Map();
for (const metric of metrics) {
latestByHost.set(metric.host_id, Math.max(latestByHost.get(metric.host_id) || 0, metric.ts || 0));
}
const freshness = (hostId) => {
const latest = latestByHost.get(hostId);
if (!latest) return 'No data';
const ageMinutes = Math.max(0, Math.floor((Date.now() / 1000 - latest) / 60));
return ageMinutes < 1 ? 'Now' : `${ageMinutes}m ago`;
};
const hosts = dashboardHosts || [];
const hostButtons = hosts.map((host) => `<button class="dashboard-filter host-filter${dashboardHostId === host.id ? ' active' : ''}" data-host="${host.id}" aria-pressed="${dashboardHostId === host.id}">
<span><strong>${escapeHtml(host.name)}</strong><small>${escapeHtml(host.kind || host.address)}</small></span>
<em>${escapeHtml(freshness(host.id))}</em>
</button>`).join('');
const categories = DASHBOARD_CATEGORIES.map((category) => `<button class="dashboard-filter category-filter${dashboardCategory === category ? ' active' : ''}" data-category="${category}" aria-pressed="${dashboardCategory === category}">${category}</button>`).join('');
return `<aside class="card dashboard-sidebar" aria-label="Dashboard filters">
<div class="sidebar-group"><div class="field-label">Hosts</div>
<button class="dashboard-filter host-filter${dashboardHostId == null ? ' active' : ''}" data-host="" aria-pressed="${dashboardHostId == null}">
<span><strong>All hosts</strong><small>${hosts.length} monitored</small></span>
</button>
<div class="host-list">${hostButtons || '<span class="empty-mini">No hosts available</span>'}</div>
</div>
<div class="sidebar-group"><div class="field-label">Metrics</div><div class="category-list">${categories}</div></div>
</aside>`;
}
function badgeKey(metrics) {
return metrics.map(metricKey).sort().join('|');
}
function updateBadges(metrics) {
latestMetrics = metrics;
if (currentView !== 'dashboard' || renderedBadgeKey === '') return;
const visible = visibleDashboardMetrics(metrics).filter((metric) => cardVisible(metric.name));
if (badgeKey(visible) !== renderedBadgeKey) {
renderDashboard(metrics); // selected host's metric set changed — rebuild
return;
}
const badges = new Map([...app.querySelectorAll('[data-badge]')].map((element) => [element.dataset.badge, element]));
for (const metric of visible) {
const el = badges.get(metricKey(metric));
if (el) {
el.textContent = formatValue(metric.name, metric.value);
el.className = `stat-value value ${severity(metric.name, metric.value)}`;
el.dataset.badge = metricKey(metric);
}
}
}
async function refreshAlerts() {
const el = $('#alerts-section');
if (!el) return;
const data = await api(`/api/alerts?hours=24${dashboardHostId == null ? '' : `&host_id=${dashboardHostId}`}`);
const active = (data.alerts || []).filter((a) => !a.resolved_at);
if (active.length === 0) { el.innerHTML = ''; return; }
let html = '<section class="card"><div class="card-header"><div class="card-title">Active alerts</div></div><div class="table-wrap"><table class="data-table"><tr><th>Rule</th><th>Metric</th><th>Value</th><th>Since</th></tr>';
for (const a of active) {
html += `<tr class="alert-active"><td>${escapeHtml(a.rule_name)}</td><td>${escapeHtml(a.metric_name)}</td><td>${a.value.toFixed(1)}</td><td>${ts(a.triggered_at)}</td></tr>`;
}
html += '</table></div></section>';
el.innerHTML = html;
}
async function drawCharts() {
if (currentSpecs.length === 0) return;
chartController?.abort();
const controller = new AbortController();
chartController = controller;
const wanted = [...new Set(currentSpecs.flatMap((s) => s.series.map((x) => x.name)))];
const params = new URLSearchParams({
names: wanted.join(','),
hours: String(rangeHours),
points: '120',
});
if (dashboardHostId != null) params.set('host_id', String(dashboardHostId));
try {
const data = await api(`/api/metrics?${params}`, { signal: controller.signal });
if (controller.signal.aborted || currentView !== 'dashboard') return;
const seriesData = new Map();
for (const metric of data.metrics || []) {
const key = `${metric.host_id}\u0000${metric.name}`;
const host = hostName(metric.host_id);
const entry = seriesData.get(key) || { host, name: metric.name, points: [] };
entry.points.push({ ts: metric.ts, value: metric.value });
seriesData.set(key, entry);
}
currentSpecs.forEach((spec, i) => {
const chart = document.getElementById(`chart-${i}`);
const legend = document.getElementById(`legend-${i}`);
if (!chart) return;
const series = spec.series.flatMap((definition) =>
[...seriesData.values()]
.filter((entry) => entry.name === definition.name)
.map((entry) => ({
color: definition.color,
label: `${entry.host} · ${definition.label || metricLabel(definition.name)}`,
points: entry.points,
}))
);
if (legend) legend.innerHTML = series.map((item) => `<span class="lg"><i style="background:${cssVar(item.color)}"></i>${escapeHtml(item.label)}</span>`).join('');
renderTimeSeriesChart(chart, series, { label: spec.title, min: spec.min, max: spec.max, fmt: spec.fmt });
});
} catch (error) {
if (error.name !== 'AbortError') throw error;
} finally {
if (chartController === controller) chartController = null;
}
}
async function renderDashboard(seedMetrics) {
await loadDashboardHosts();
let metrics = seedMetrics || latestMetrics;
if (metrics.length === 0) {
const status = await api('/api/status');
if (currentView !== 'dashboard') return;
metrics = status.metrics || [];
latestMetrics = metrics;
}
if (dashboardHostId != null && dashboardHosts.length > 0 && !dashboardHosts.some((host) => host.id === dashboardHostId)) {
dashboardHostId = null;
syncDashboardUrl();
}
const hostMetrics = metrics.filter((metric) => dashboardHostId == null || metric.host_id === dashboardHostId);
const visible = visibleDashboardMetrics(metrics).filter((metric) => cardVisible(metric.name));
const sorted = sortMetrics(visible);
renderedBadgeKey = badgeKey(visible);
currentSpecs = chartSpecs(hostMetrics.map((metric) => metric.name))
.filter((spec) => dashboardCategory === 'All' || spec.category === dashboardCategory);
const scopeName = dashboardHostId == null ? 'All hosts' : hostName(dashboardHostId);
let content = `<div class="refresh conn">${connLabel()}</div>`;
content += `<div class="section-heading"><div><h1>${escapeHtml(scopeName)}</h1><p>Live host telemetry and service health.</p></div></div>`;
if (sorted.length === 0) {
content += '<div class="empty-state card">No metrics match these filters.</div>';
} else {
content += '<div class="stats-grid">';
for (const metric of sorted) {
const sev = severity(metric.name, metric.value);
const label = dashboardHostId == null ? `${hostName(metric.host_id)} · ${metricLabel(metric.name)}` : metricLabel(metric.name);
content += `<div class="stat-card"><div class="stat-value value ${sev}" data-badge="${escapeHtml(metricKey(metric))}">${formatValue(metric.name, metric.value)}</div><div class="stat-label">${escapeHtml(label)}</div></div>`;
}
content += '</div>';
}
content += '<div class="trend-head"><div><h2>Trends</h2><p>Historical metrics for the selected host and category.</p></div><div class="ranges">';
for (const [hours, label] of RANGES) {
content += `<button class="btn btn-secondary btn-sm range${hours === rangeHours ? ' active' : ''}" data-hours="${hours}">${label}</button>`;
}
content += '</div></div>';
const chartCard = (spec, index) => `<section class="card chart-card"><div class="card-header"><div class="card-title">${escapeHtml(spec.title)}</div></div><div class="legend" id="legend-${index}"></div><div class="chart-host" id="chart-${index}"></div></section>`;
if (currentSpecs.length === 0) {
content += '<div class="empty-state card">No trend graphs match these filters.</div>';
} else {
for (const category of orderedCategories(currentSpecs)) {
content += `<h3 class="chart-group">${category}</h3><div class="charts">`;
currentSpecs.forEach((spec, index) => {
if ((spec.category || 'Other') === category) content += chartCard(spec, index);
});
content += '</div>';
}
}
content += '<div id="alerts-section"></div>';
app.innerHTML = `<div class="dashboard-layout">${renderDashboardSidebar(metrics)}<div class="dashboard-content">${content}</div></div>`;
app.querySelectorAll('[data-host]').forEach((button) => {
button.addEventListener('click', () => {
dashboardHostId = button.dataset.host ? Number(button.dataset.host) : null;
syncDashboardUrl();
renderDashboard(latestMetrics);
});
});
app.querySelectorAll('[data-category]').forEach((button) => {
button.addEventListener('click', () => {
dashboardCategory = button.dataset.category;
syncDashboardUrl();
renderDashboard(latestMetrics);
});
});
app.querySelectorAll('button.range').forEach((button) => {
button.addEventListener('click', () => {
rangeHours = Number(button.dataset.hours);
app.querySelectorAll('button.range').forEach((item) => item.classList.toggle('active', item === button));
syncDashboardUrl();
drawCharts();
});
});
drawCharts();
refreshAlerts();
// periodic refresh of history-backed charts + alerts (badges update live via SSE)
clearInterval(chartTimer);
chartTimer = setInterval(() => { drawCharts(); refreshAlerts(); }, 60000);
}
// ── Logs ───────────────────────────────────────────────────────────
const LOG_RANGES = [[1, '1h'], [6, '6h'], [24, '24h']];
function logSources() {
return [...new Set(liveLogs.map((l) => l.source))].sort();
}
function srcName(source) {
return source.split('/').pop();
}
const LEVEL_RANK = { trace: 0, debug: 1, info: 2, warn: 3, error: 4 };
// Deterministic categorisation + level detection for known log formats.
function classifyLog(source, line) {
const s = source.toLowerCase();
let category = 'System';
if (s.includes('nginx')) category = 'HTTP';
else if (s.includes('auth')) category = 'Auth';
else if (s.includes('mail') || s.includes('postfix') || s.includes('dovecot')) category = 'Mail';
else if (s.includes('dns')) category = 'DNS';
else if (s.includes('idp') || s.includes('monitor') || s.includes('akurai')) category = 'App';
let level = 'info';
// nginx access log: "...\"GET /path HTTP/1.1\" 503 ..."
const http = line.match(/"\s*(?:GET|POST|PUT|DELETE|HEAD|PATCH|OPTIONS)\b[^"]*"\s+(\d{3})/);
// Rust tracing / many app logs: a bare level token
const tr = line.match(/\b(TRACE|DEBUG|INFO|WARN|ERROR)\b/);
if (category === 'HTTP' && http) {
const code = +http[1];
category = 'HTTP';
level = code >= 500 ? 'error' : code >= 400 ? 'warn' : 'info';
} else if (tr) {
const t = tr[1];
level = t === 'ERROR' ? 'error' : t === 'WARN' ? 'warn' : t === 'TRACE' || t === 'DEBUG' ? 'debug' : 'info';
} else if (/\b(fatal|panic|critical|segfault|err(?:or)?|failed|failure|denied|refused|reject)\b/i.test(line)) {
level = 'error';
} else if (/\b(warn(?:ing)?|deprecat\w*|timeout|timed out|retry|retrying|throttl)\b/i.test(line)) {
level = 'warn';
}
// Auth-specific refinements
if (category === 'Auth') {
if (/Failed password|authentication failure|invalid user|Failed |Disconnect/i.test(line)) level = 'warn';
if (/Accepted (?:password|publickey)/i.test(line)) level = 'info';
}
return { level, category };
}
function logCategories() {
return [...new Set(liveLogs.map((l) => classifyLog(l.source, l.line).category))].sort();
}
function renderLogRows() {
const tbody = document.getElementById('log-rows');
const countEl = document.getElementById('log-count');
if (!tbody) return;
const minRank = logLevel === 'error' ? 4 : logLevel === 'warn' ? 3 : 0;
const rows = liveLogs
.map((l) => {
const c = classifyLog(l.source, l.line);
// First-class server-provided level wins over heuristic detection.
return { ...l, category: c.category, level: l.level || c.level };
})
.filter(
(l) =>
!logExcluded.has(l.source) &&
!logExcludedCats.has(l.category) &&
LEVEL_RANK[l.level] >= minRank &&
(logSearch === '' || l.line.toLowerCase().includes(logSearch))
);
if (countEl) countEl.textContent = `${rows.length} line${rows.length === 1 ? '' : 's'}`;
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">No matching log entries</td></tr>';
return;
}
tbody.innerHTML = rows
.map(
(l) =>
`<tr class="lvl-${l.level}${l.origin === 'client' ? ' origin-client' : ''}"><td class="t">${ts(l.ts)}</td><td class="lv"><span class="badge badge-${l.level === 'error' ? 'danger' : l.level === 'warn' ? 'warn' : l.level === 'info' ? 'ok' : 'muted'}">${l.level}</span></td><td class="s">${escapeHtml(srcName(l.source))}${l.origin === 'client' ? '<span class="cat">client</span>' : ''}</td><td class="line"><span class="cat">${escapeHtml(l.category)}</span>${l.target ? `<span class="cat">${escapeHtml(l.target)}</span>` : ''}${escapeHtml(l.line)}</td></tr>`
)
.join('');
}
function renderLogsLayout() {
const sources = logSources();
logSourceKey = sources.join('|');
const counts = {};
for (const l of liveLogs) counts[l.source] = (counts[l.source] || 0) + 1;
const cats = logCategories();
const catCounts = {};
for (const l of liveLogs) {
const c = classifyLog(l.source, l.line).category;
catCounts[c] = (catCounts[c] || 0) + 1;
}
let sidebar = '<aside class="card log-filters">';
sidebar += '<div class="fgroup"><label class="field-label">Range</label><div class="ranges">';
for (const [h, lbl] of LOG_RANGES) {
sidebar += `<button class="btn btn-secondary btn-sm range${h === logRangeHours ? ' active' : ''}" data-hours="${h}">${lbl}</button>`;
}
sidebar += '</div></div>';
sidebar += '<div class="fgroup"><label class="field-label">Level</label><div class="ranges">';
for (const [val, lbl] of [['all', 'All'], ['warn', 'Warn+'], ['error', 'Errors']]) {
sidebar += `<button class="btn btn-secondary btn-sm range${val === logLevel ? ' active' : ''}" data-level="${val}">${lbl}</button>`;
}
sidebar += '</div></div>';
sidebar += `<div class="fgroup"><label class="field-label" for="log-search">Search</label><input id="log-search" type="search" placeholder="Filter text…" value="${escapeHtml(logSearch)}"></div>`;
if (cats.length > 0) {
sidebar += '<div class="fgroup"><label class="field-label">Category</label><div class="sources">';
for (const c of cats) {
const checked = logExcludedCats.has(c) ? '' : 'checked';
sidebar += `<label class="src"><input type="checkbox" data-cat="${escapeHtml(c)}" ${checked}><span>${escapeHtml(c)}</span><em>${catCounts[c] || 0}</em></label>`;
}
sidebar += '</div></div>';
}
sidebar += '<div class="fgroup"><label class="field-label">Sources</label><div class="sources">';
if (sources.length === 0) {
sidebar += '<span class="empty-mini">none yet</span>';
} else {
for (const s of sources) {
const checked = logExcluded.has(s) ? '' : 'checked';
sidebar += `<label class="src"><input type="checkbox" data-source="${escapeHtml(s)}" ${checked}><span>${escapeHtml(srcName(s))}</span><em>${counts[s] || 0}</em></label>`;
}
}
sidebar += '</div></div></aside>';
const content =
'<div class="log-content">' +
`<div class="log-head"><span class="conn">${connLabel()}</span><span id="log-count" class="log-count"></span></div>` +
'<div class="log-scroll table-wrap"><table class="data-table logtable"><colgroup><col class="c-t"><col class="c-lv"><col class="c-s"><col class="c-l"></colgroup>' +
'<thead><tr><th>Time</th><th>Level</th><th>Source</th><th>Line</th></tr></thead>' +
'<tbody id="log-rows"></tbody></table></div></div>';
app.innerHTML = `<div class="logs-layout">${sidebar}${content}</div>`;
app.querySelectorAll('.log-filters button[data-hours]').forEach((btn) => {
btn.addEventListener('click', () => {
logRangeHours = Number(btn.dataset.hours);
renderLogs();
});
});
app.querySelectorAll('.log-filters button[data-level]').forEach((btn) => {
btn.addEventListener('click', () => {
logLevel = btn.dataset.level;
app.querySelectorAll('.log-filters button[data-level]').forEach((b) => b.classList.toggle('active', b === btn));
renderLogRows();
});
});
const search = document.getElementById('log-search');
if (search) {
search.addEventListener('input', () => {
logSearch = search.value.trim().toLowerCase();
renderLogRows();
});
}
app.querySelectorAll('.log-filters input[data-source]').forEach((cb) => {
cb.addEventListener('change', () => {
const src = cb.dataset.source;
if (cb.checked) logExcluded.delete(src); else logExcluded.add(src);
renderLogRows();
});
});
app.querySelectorAll('.log-filters input[data-cat]').forEach((cb) => {
cb.addEventListener('change', () => {
const cat = cb.dataset.cat;
if (cb.checked) logExcludedCats.delete(cat); else logExcludedCats.add(cat);
renderLogRows();
});
});
renderLogRows();
}
async function renderLogs() {
const data = await api(`/api/logs?hours=${logRangeHours}&limit=300`);
if (currentView !== 'logs') return;
liveLogs = data.logs || []; // newest first
renderLogsLayout();
}
function handleLogBatch(data) {
if (currentView !== 'logs') return;
const incoming = data.logs || [];
if (incoming.length === 0) return;
// incoming is chronological (oldest first); prepend newest-first
liveLogs = incoming.slice().reverse().concat(liveLogs).slice(0, LOG_CAP);
// rebuild the sidebar only if a new source appeared (keeps search focus otherwise)
if (logSources().join('|') !== logSourceKey) renderLogsLayout();
else renderLogRows();
}
// ── Alerts (full history view) ─────────────────────────────────────
async function renderAlerts() {
const data = await api('/api/alerts?hours=72');
if (currentView !== 'alerts') return;
const alerts = data.alerts || [];
let html = `<div class="section-heading"><div><h1>Alerts</h1><p>Last 72 hours · ${data.active || 0} active</p></div></div>`;
if (alerts.length === 0) {
html += '<div class="empty-state">No alerts</div>';
} else {
html += '<section class="card table-wrap"><table class="data-table"><tr><th>Status</th><th>Rule</th><th>Metric</th><th>Value</th><th>Triggered</th><th>Resolved</th></tr>';
for (const a of alerts) {
const cls = a.resolved_at ? 'alert-resolved' : 'alert-active';
const status = a.resolved_at ? 'Resolved' : 'Active';
html += `<tr class="${cls}"><td><span class="badge badge-${a.resolved_at ? 'ok' : 'danger'}">${status}</span></td><td>${escapeHtml(a.rule_name)}</td><td>${escapeHtml(a.metric_name)}</td><td>${a.value.toFixed(1)}</td><td>${ts(a.triggered_at)}</td><td>${a.resolved_at ? ts(a.resolved_at) : '-'}</td></tr>`;
}
html += '</table></section>';
}
app.innerHTML = html;
}
// ── Versions ───────────────────────────────────────────────────────
function compareSemver(left, right) {
const a = String(left).split('.').map((part) => Number.parseInt(part, 10) || 0);
const b = String(right).split('.').map((part) => Number.parseInt(part, 10) || 0);
for (let index = 0; index < Math.max(a.length, b.length); index++) {
if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) < (b[index] || 0) ? -1 : 1;
}
return 0;
}
function reportedAge(epoch) {
if (!epoch) return 'Never';
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - epoch));
if (seconds < 60) return 'Just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
async function renderVersions() {
app.innerHTML = '<div class="empty-state">Loading version inventory…</div>';
try {
const [applicationData, status] = await Promise.all([manageApi('/api/manage/applications'), api('/api/status'), loadDashboardHosts()]);
if (currentView !== 'versions') return;
const applications = applicationData.items || applicationData.applications || [];
const latest = status.framework_latest;
const stateOf = (item) => item.framework_version
? (compareSemver(item.framework_version, latest) < 0 ? 'outdated' : 'current')
: (item.versions_reported_at ? 'na' : 'unknown');
const states = applications.map(stateOf);
const label = { current: 'current', outdated: 'outdated', na: 'n/a', unknown: 'unknown' };
const badge = { current: 'ok', outdated: 'warn', na: 'muted', unknown: 'muted' };
let html = `<div class="section-heading"><div><h1>Framework versions</h1><p>Monitor baseline: ${escapeHtml(latest || 'unknown')}</p></div></div><div class="stats-grid">`;
html += `<div class="stat-card"><div class="stat-value">${applications.length}</div><div class="stat-label">Total apps</div></div>`;
html += `<div class="stat-card"><div class="stat-value version-current">${states.filter((state) => state === 'current').length}</div><div class="stat-label">On latest</div></div>`;
html += `<div class="stat-card"><div class="stat-value version-outdated">${states.filter((state) => state === 'outdated').length}</div><div class="stat-label">Outdated</div></div></div>`;
html += '<section class="card table-wrap"><div class="card-header"><div class="card-title">Application inventory</div></div><table class="data-table"><thead><tr><th>Application</th><th>Host</th><th>App version</th><th>Framework</th><th>Framework version</th><th>Reported</th></tr></thead><tbody>';
html += applications.map((item) => {
const state = stateOf(item);
return `<tr><td>${escapeHtml(item.name || item.application || 'Unknown')}</td><td>${escapeHtml(hostNames?.get(item.host_id) || `Host ${item.host_id}`)}</td><td>${escapeHtml(item.app_version || '—')}</td><td>${escapeHtml(item.framework || '—')}</td><td><span class="badge badge-${badge[state]}">${label[state]}</span> ${escapeHtml(item.framework_version || '—')}</td><td>${reportedAge(item.versions_reported_at)}</td></tr>`;
}).join('') || '<tr><td colspan="6" class="empty-state">No applications have reported versions.</td></tr>';
app.innerHTML = html + '</tbody></table></section>';
} catch (error) {
if (currentView === 'versions') app.innerHTML = `<div class="empty-state error-text">${escapeHtml(error.message)}</div>`;
}
}
// ── Receivers ──────────────────────────────────────────────────────
async function renderReceivers() {
app.innerHTML = '<div class="empty">Loading receiver settings…</div>';
try {
const [channelData, recipientData] = await Promise.all([
manageApi('/api/manage/channels'),
manageApi('/api/manage/recipients'),
]);
if (currentView !== 'receivers') return;
const channels = channelData.items || [];
const recipients = recipientData.items || [];
const piBun = channels.find((channel) => channel.kind === 'pi-bun');
if (!piBun) throw new Error('PI-BUN delivery is not configured');
const targetData = await manageApi(`/api/manage/channels/${piBun.id}/targets`);
if (currentView !== 'receivers') return;
const targets = targetData.targets || [];
if (!targets.some((target) => target.value === receiverTarget)) {
receiverTarget = targets.find((target) => target.value === 'errors')?.value || targets[0]?.value || '';
}
const recipientRows = recipients.map((recipient) => {
const channel = channels.find((item) => item.id === recipient.channel_id);
return `<div class="receiver-row">
<div><strong>${escapeHtml(recipient.name)}</strong><span>${escapeHtml(channel?.name || 'Unknown')} · #${escapeHtml(recipient.target)}</span></div>
<span class="badge badge-${recipient.enabled ? 'ok' : 'warn'}">${recipient.enabled ? 'Enabled' : 'Paused'}</span>
<div class="receiver-actions">
<button class="btn btn-secondary btn-sm" data-toggle="${recipient.id}" data-enabled="${recipient.enabled ? 0 : 1}">${recipient.enabled ? 'Pause' : 'Enable'}</button>
<button class="btn btn-ghost btn-sm danger" data-remove="${recipient.id}" data-name="${escapeHtml(recipient.name)}">Remove</button>
</div>
</div>`;
}).join('');
app.innerHTML = `<section class="receivers-page">
<div class="settings-heading"><div><h1>Receiver settings</h1><p>Choose where Monitor sends triggered, resolved, and test alerts.</p></div><button class="btn btn-secondary" id="test-receivers">Send test</button></div>
<div class="card receiver-card">
<div class="receiver-step"><span>1</span><div><strong>Delivery service</strong><p>More services can be added later.</p></div></div>
<div class="service-choice selected"><strong>PI-BUN</strong><span>Workbench channels on Titan</span></div>
<div class="receiver-step"><span>2</span><div><strong>Select a channel</strong><p>The alert appears as a display-only message and does not start an agent.</p></div></div>
<div class="target-list">${targets.length ? targets.map((target) => `<button class="btn btn-secondary ${target.value === receiverTarget ? 'selected' : ''}" data-target="${escapeHtml(target.value)}"><span>#</span>${escapeHtml(target.value)}</button>`).join('') : '<span class="empty-mini">No PI-BUN channels available</span>'}</div>
<button class="btn btn-primary" id="add-receiver" ${receiverTarget ? '' : 'disabled'}>Add receiver</button>
</div>
<div class="card receiver-list"><div class="card-header"><div class="card-title">Configured receivers <span>${recipients.length}</span></div></div>${recipientRows || '<div class="empty-state">No receivers configured</div>'}</div>
</section>`;
app.querySelectorAll('[data-target]').forEach((button) => button.addEventListener('click', () => {
receiverTarget = button.dataset.target;
app.querySelectorAll('[data-target]').forEach((item) => item.classList.toggle('selected', item === button));
}));
$('#add-receiver').addEventListener('click', async () => {
try {
await manageApi('/api/manage/recipients', {
method: 'POST',
body: JSON.stringify({ channel_id: piBun.id, name: `PI-BUN #${receiverTarget}`, target: receiverTarget, enabled: 1 }),
});
showToast(`Receiver #${receiverTarget} added`);
await renderReceivers();
} catch (error) {
showToast(error.message, 'error');
}
});
$('#test-receivers').addEventListener('click', async () => {
try {
const result = await manageApi(`/api/manage/channels/${piBun.id}/test`, { method: 'POST' });
showToast(`Test sent to ${result.sent} receiver${result.sent === 1 ? '' : 's'}`);
} catch (error) {
showToast(error.message, 'error');
}
});
app.querySelectorAll('[data-toggle]').forEach((button) => button.addEventListener('click', async () => {
try {
await manageApi(`/api/manage/recipients/${button.dataset.toggle}`, {
method: 'PUT',
body: JSON.stringify({ enabled: Number(button.dataset.enabled) }),
});
showToast(Number(button.dataset.enabled) ? 'Receiver enabled' : 'Receiver paused');
await renderReceivers();
} catch (error) {
showToast(error.message, 'error');
}
}));
app.querySelectorAll('[data-remove]').forEach((button) => button.addEventListener('click', async () => {
const confirmed = await confirmAction('Remove receiver?', `${button.dataset.name} will stop receiving alerts.`, 'Remove');
if (!confirmed) return;
try {
await manageApi(`/api/manage/recipients/${button.dataset.remove}`, { method: 'DELETE' });
showToast('Receiver removed');
await renderReceivers();
} catch (error) {
showToast(error.message, 'error');
}
}));
} catch (error) {
if (currentView === 'receivers') app.innerHTML = `<div class="empty error-text">${escapeHtml(error.message)}</div>`;
}
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
})[character]);
}
// ── Live connection (SSE with polling fallback) ────────────────────
function handleStatus(data) {
updateBadges(data.metrics || []);
}
let pollTimer = null;
function startPolling() {
if (pollTimer) return;
connState = 'polling';
updateConnUI();
pollTimer = setInterval(async () => {
try {
const s = await api('/api/status');
handleStatus(s);
if (currentView === 'logs') await renderLogs();
} catch { /* ignore */ }
}, 10000);
}
function startLive() {
if (typeof EventSource === 'undefined') { startPolling(); return; }
let opened = false;
const es = new EventSource('/api/stream');
es.onopen = () => { opened = true; connState = 'live'; updateConnUI(); };
es.addEventListener('status', (e) => { try { handleStatus(JSON.parse(e.data)); } catch {} });
es.addEventListener('log', (e) => { try { handleLogBatch(JSON.parse(e.data)); } catch {} });
es.onerror = () => {
if (!opened) { es.close(); startPolling(); } // stream blocked → fall back
else { connState = 'connecting'; updateConnUI(); } // transient: let it auto-reconnect
};
}
// ── Routing ────────────────────────────────────────────────────────
function readDashboardFilters(hash) {
const params = new URLSearchParams(hash.split('?')[1] || '');
const host = params.get('host');
dashboardHostId = host && /^\d+$/.test(host) ? Number(host) : null;
const category = params.get('category');
dashboardCategory = DASHBOARD_CATEGORIES.includes(category) ? category : 'All';
const hours = Number(params.get('hours'));
rangeHours = RANGES.some(([value]) => value === hours) ? hours : 24;
}
function syncDashboardUrl() {
if (currentView !== 'dashboard') return;
const params = new URLSearchParams();
if (dashboardHostId != null) params.set('host', String(dashboardHostId));
if (dashboardCategory !== 'All') params.set('category', dashboardCategory);
if (rangeHours !== 24) params.set('hours', String(rangeHours));
history.replaceState(null, '', `#/${params.size ? `?${params}` : ''}`);
}
function route() {
const hash = location.hash || '#/';
const routePath = hash.split('?')[0];
document.querySelectorAll('nav a').forEach((a) => {
a.classList.toggle('active', a.getAttribute('href') === routePath);
});
clearInterval(chartTimer);
chartController?.abort();
chartController = null;
if (hash.startsWith('#/logs')) {
currentView = 'logs';
renderLogs();
} else if (hash.startsWith('#/alerts')) {
currentView = 'alerts';
renderAlerts();
} else if (hash.startsWith('#/versions')) {
currentView = 'versions';
renderVersions();
} else if (hash.startsWith('#/receivers')) {
currentView = 'receivers';
renderReceivers();
} else {
readDashboardFilters(hash);
currentView = 'dashboard';
renderDashboard();
}
}
window.addEventListener('hashchange', route);
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { if (currentView === 'dashboard') drawCharts(); }, 200);
});
route();
startLive();