/* =====================================================
Monitoring Dashboard — frontend logic (PHP-only, polling)
-----------------------------------------------------
Every POLL_MS ms we hit /api/query.php?action=live with the
highest ID we've seen for each stream. The server returns only
rows newer than those cursors. This is cheap: the PK index makes
"WHERE id > ?" essentially free.
Polling feels as live as 2s — plenty for a monitoring dashboard.
===================================================== */
(() => {
'use strict';
// --- CONFIG ---------------------------------------------------
const CONFIG = {
queryBase: '/monitoring/php/api/query.php',
dashboardToken: 'REPLACE_WITH_RANDOM_HEX_64',
pollMs: 2000, // how often to fetch new data
slowPollMs: 10000, // back off to this after repeated failures
};
// --- STATE ---------------------------------------------------
const state = {
systemFilter: '',
window: 60,
cursors: { metric: 0, error: 0, alert: 0 },
currentPollMs: CONFIG.pollMs,
consecutiveFailures: 0,
pollTimer: null,
metricsSinceLastRate: 0,
rateTimer: null,
};
const $ = (id) => document.getElementById(id);
const fmtTime = (ts) => {
const d = ts instanceof Date ? ts : new Date(ts);
return d.toTimeString().slice(0,8);
};
// --- API HELPER ----------------------------------------------
async function apiGet(action, params = {}) {
const q = new URLSearchParams({ action, ...params }).toString();
const res = await fetch(`${CONFIG.queryBase}?${q}`, {
headers: { 'X-Dashboard-Token': CONFIG.dashboardToken },
});
if (!res.ok) throw new Error(`${action} ${res.status}`);
return res.json();
}
// --- CHARTS --------------------------------------------------
let rtChart, volChart;
function initCharts() {
const baseOpts = {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { labels: { color: '#9da7b3' } } },
scales: {
x: { ticks: { color: '#9da7b3' }, grid: { color: '#2a323d' } },
y: { ticks: { color: '#9da7b3' }, grid: { color: '#2a323d' }, beginAtZero: true },
},
};
rtChart = new Chart($('rtChart').getContext('2d'), {
type: 'line',
data: { labels: [], datasets: [] },
options: baseOpts,
});
volChart = new Chart($('volChart').getContext('2d'), {
type: 'bar',
data: { labels: [], datasets: [] },
options: baseOpts,
});
}
function updateCharts(series) {
const bySystem = {};
const buckets = new Set();
for (const row of series) {
buckets.add(row.bucket);
(bySystem[row.system_name] ||= {})[row.bucket] = row;
}
const labels = [...buckets].sort();
const palette = ['#58a6ff', '#bc8cff', '#3fb950', '#d29922', '#f85149'];
const rtDatasets = Object.keys(bySystem).map((sys, i) => ({
label: sys,
data: labels.map(b => bySystem[sys][b] ? +bySystem[sys][b].avg_rt : null),
borderColor: palette[i % palette.length],
backgroundColor: palette[i % palette.length] + '22',
tension: 0.3,
spanGaps: true,
pointRadius: 2,
}));
const volDatasets = Object.keys(bySystem).map((sys, i) => ({
label: sys,
data: labels.map(b => bySystem[sys][b] ? +bySystem[sys][b].cnt : 0),
backgroundColor: palette[i % palette.length] + 'aa',
}));
rtChart.data.labels = labels.map(s => s.slice(11, 16));
rtChart.data.datasets = rtDatasets;
rtChart.update();
volChart.data.labels = labels.map(s => s.slice(11, 16));
volChart.data.datasets = volDatasets;
volChart.update();
}
// --- SYSTEM CARDS --------------------------------------------
function renderSystemCards(overview) {
const container = $('systemCards');
const stats = overview.stats || {};
container.innerHTML = '';
const filter = $('systemFilter');
const current = filter.value;
while (filter.options.length > 1) filter.remove(1);
for (const sys of overview.systems) {
const o = document.createElement('option');
o.value = sys.name; o.textContent = sys.name;
filter.appendChild(o);
}
filter.value = current;
for (const sys of overview.systems) {
const s = stats[sys.name] || {};
const card = document.createElement('div');
card.className = `sys ${sys.status}`;
card.innerHTML = `
${sys.name}
${sys.status.toUpperCase()} · last seen ${sys.last_seen ? fmtTime(sys.last_seen) : 'never'}
${s.avg_rt ? Math.round(s.avg_rt) + ' ms' : '—'}
${s.total || 0} req / 5m · ${+s.errors_5xx||0} 5xx · ${+s.errors_4xx||0} 4xx
`;
container.appendChild(card);
}
}
// Update just the status badges without re-rendering the whole grid
function updateSystemStatuses(systems) {
const map = {};
for (const s of systems) map[s.name] = s;
document.querySelectorAll('.sys').forEach(c => {
const name = c.querySelector('.name')?.textContent.trim();
const s = map[name];
if (!s) return;
c.className = `sys ${s.status}`;
const meta = c.querySelectorAll('.meta')[0];
if (meta) meta.textContent = `${s.status.toUpperCase()} · last seen ${s.last_seen ? fmtTime(s.last_seen) : 'never'}`;
});
}
// --- STREAMS -------------------------------------------------
const MAX_ROWS = 120;
function prependRow(el, html, extraClass = '') {
const div = document.createElement('div');
div.className = 'row ' + extraClass;
div.innerHTML = html;
el.prepend(div);
while (el.children.length > MAX_ROWS) el.removeChild(el.lastChild);
}
function renderMetric(m) {
if (state.systemFilter && m.system_name !== state.systemFilter) return;
const stClass = m.status_code >= 500 ? 'err' : m.status_code >= 400 ? 'wrn' : 'ok';
prependRow($('metricStream'),
`${fmtTime(m.created_at)}
${m.system_name}
${m.method} ${escapeHtml(m.endpoint)}
${m.response_time}ms
${m.status_code}`);
state.metricsSinceLastRate++;
}
function renderError(e) {
if (state.systemFilter && e.system_name !== state.systemFilter) return;
prependRow($('errorStream'),
`${fmtTime(e.created_at)}
${e.system_name}
${escapeHtml(e.message)}`);
$('errorStream').classList.add('errors');
}
function renderAlert(a) {
prependRow($('alertStream'),
`${fmtTime(a.created_at)}
${a.system_name}
[${a.alert_type}] ${escapeHtml(a.message)}`,
a.severity || 'warning');
$('alertStream').classList.add('alerts');
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g,
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
// --- INITIAL LOAD --------------------------------------------
async function initialLoad() {
try {
const overview = await apiGet('overview');
renderSystemCards(overview);
const metrics = await apiGet('metrics', { limit: 60, system: state.systemFilter });
$('metricStream').innerHTML = '';
metrics.items.slice().reverse().forEach(m => renderMetric(m));
if (metrics.items.length) {
state.cursors.metric = Math.max(...metrics.items.map(m => +m.id));
}
const errors = await apiGet('errors', { limit: 30, system: state.systemFilter });
$('errorStream').innerHTML = '';
errors.items.slice().reverse().forEach(e => renderError(e));
if (errors.items.length) {
state.cursors.error = Math.max(...errors.items.map(e => +e.id));
}
const alerts = await apiGet('alerts', { limit: 20 });
$('alertStream').innerHTML = '';
alerts.items.slice().reverse().forEach(a => renderAlert(a));
if (alerts.items.length) {
state.cursors.alert = Math.max(...alerts.items.map(a => +a.id));
}
const ts = await apiGet('timeseries', { window: state.window, system: state.systemFilter });
updateCharts(ts.series);
} catch (e) {
console.error('initial load failed', e);
}
}
// --- POLL LOOP -----------------------------------------------
async function pollTick() {
try {
const data = await apiGet('live', {
since_metric: state.cursors.metric,
since_error: state.cursors.error,
since_alert: state.cursors.alert,
system: state.systemFilter,
});
for (const m of data.metrics) {
renderMetric(m);
if (+m.id > state.cursors.metric) state.cursors.metric = +m.id;
}
for (const e of data.errors) {
renderError(e);
if (+e.id > state.cursors.error) state.cursors.error = +e.id;
}
for (const a of data.alerts) {
renderAlert(a);
if (+a.id > state.cursors.alert) state.cursors.alert = +a.id;
}
updateSystemStatuses(data.systems || []);
// Healthy poll — reset failure counter + ensure fast polling
state.consecutiveFailures = 0;
if (state.currentPollMs !== CONFIG.pollMs) {
state.currentPollMs = CONFIG.pollMs;
}
$('connDot').classList.add('ok');
} catch (err) {
state.consecutiveFailures++;
$('connDot').classList.remove('ok');
// After 3 failures in a row, slow down to reduce server load
if (state.consecutiveFailures >= 3) {
state.currentPollMs = CONFIG.slowPollMs;
}
} finally {
state.pollTimer = setTimeout(pollTick, state.currentPollMs);
}
}
// --- CONTROLS ------------------------------------------------
$('systemFilter').addEventListener('change', (e) => {
state.systemFilter = e.target.value;
// Reset cursors so we re-load the filtered view from scratch
state.cursors = { metric: 0, error: 0, alert: 0 };
initialLoad();
});
$('windowFilter').addEventListener('change', async (e) => {
state.window = +e.target.value;
try {
const ts = await apiGet('timeseries', { window: state.window, system: state.systemFilter });
updateCharts(ts.series);
} catch {}
});
// Every 15s, refresh the aggregates (system card stats + charts)
setInterval(async () => {
try {
const overview = await apiGet('overview');
renderSystemCards(overview);
const ts = await apiGet('timeseries', { window: state.window, system: state.systemFilter });
updateCharts(ts.series);
} catch {}
}, 15000);
// Request rate indicator
state.rateTimer = setInterval(() => {
$('metricRate').textContent = `(${state.metricsSinceLastRate}/s)`;
state.metricsSinceLastRate = 0;
}, 1000);
// Pause polling when tab is hidden (saves resources)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (state.pollTimer) { clearTimeout(state.pollTimer); state.pollTimer = null; }
} else if (!state.pollTimer) {
pollTick();
}
});
// --- BOOT ----------------------------------------------------
initCharts();
initialLoad().then(pollTick);
})();