/* ===================================================== 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); })();