/* ========================================================================= SRE MONITORING DASHBOARD Vanilla JS. No frameworks, no external chart libraries. LIVE DATA SOURCES (NO LOG FILES) ------------------------------- 1. /health → { "status": "healthy" } Live HTTP GET health check. Evaluates service status (Healthy/Unhealthy) and measures real round-trip latency in milliseconds. 2. /metrics → { "requests": } Live HTTP GET metrics check. Retrieves authoritative total request count tracked by the Go service backend. ========================================================================= */ const CONFIG = { primaryBaseUrl: 'https://gheyma-app-2b88268529f9.hosted.ghaymah.systems', fallbackBaseUrl: 'http://localhost:8080', healthPath: '/health', metricsPath: '/metrics', maxHistoryPoints: 30, // recent health check data points for charts autoRefreshMs: 15000, uptimeBuckets: 48, // signature strip check count requestTimeoutMs: 6000, }; /* ========================================================================= Persistent Request Cache (localStorage) Prevents Total Requests from resetting to 0 when Go server restarts/hibernates ========================================================================= */ const CACHE_KEYS = { CUMULATIVE_TOTAL: 'ghaymah_cumulative_total_requests', LAST_RAW_COUNT: 'ghaymah_last_raw_server_requests', }; function getStoredTotalRequests() { try { const val = localStorage.getItem(CACHE_KEYS.CUMULATIVE_TOTAL); return val ? parseInt(val, 10) || 0 : 0; } catch (_) { return 0; } } function updateStoredTotalRequests(serverRequests) { try { let accumulatedTotal = getStoredTotalRequests(); const lastRawStr = localStorage.getItem(CACHE_KEYS.LAST_RAW_COUNT); const lastRaw = lastRawStr !== null ? parseInt(lastRawStr, 10) || 0 : null; if (lastRaw === null) { accumulatedTotal = Math.max(accumulatedTotal, serverRequests); } else if (serverRequests >= lastRaw) { const delta = serverRequests - lastRaw; accumulatedTotal += delta; } else { // Server restarted or hibernated (serverCount reset to smaller value) accumulatedTotal += serverRequests; } localStorage.setItem(CACHE_KEYS.CUMULATIVE_TOTAL, accumulatedTotal.toString()); localStorage.setItem(CACHE_KEYS.LAST_RAW_COUNT, serverRequests.toString()); return accumulatedTotal; } catch (_) { return serverRequests; } } /* ========================================================================= State ========================================================================= */ const state = { history: [], // [{ok, latencyMs, httpCode, timestamp, raw, error, url}] events: [], // [{time, level, message}] totals: { total: getStoredTotalRequests(), success: 0, failed: 0, totalSource: 'metrics' }, uptimeBuckets: [], // [{state: 'ok'|'down'}] isChecking: false, metricsError: null, activeBaseUrl: CONFIG.primaryBaseUrl, }; /* ========================================================================= Live HTTP Fetch Helpers ========================================================================= */ async function fetchHealthFromUrl(url) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs); const startTime = performance.now(); try { const res = await fetch(url, { signal: controller.signal, cache: 'no-store' }); const endTime = performance.now(); const latencyMs = Math.round(endTime - startTime); let body = null; let rawText = ''; try { rawText = await res.text(); body = JSON.parse(rawText); } catch (_) {} const isHealthy = res.ok && body && body.status === 'healthy'; return { ok: isHealthy, httpCode: res.status, latencyMs, timestamp: new Date(), raw: rawText || (body ? JSON.stringify(body) : `HTTP ${res.status}`), isNetworkError: false, error: isHealthy ? null : `HTTP ${res.status}${body && body.status ? ` (${body.status})` : ''}`, url, }; } catch (err) { const endTime = performance.now(); const latencyMs = Math.round(endTime - startTime); const isAbort = err.name === 'AbortError'; const isNetworkError = !isAbort && err.name === 'TypeError'; return { ok: false, httpCode: null, latencyMs, timestamp: new Date(), raw: null, isNetworkError, error: isAbort ? 'request timed out' : (isNetworkError ? 'network/CORS error — server unreachable' : (err.message || 'network error')), url, }; } finally { clearTimeout(timeout); } } async function fetchMetricsFromUrl(url) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs); try { const res = await fetch(url, { signal: controller.signal, cache: 'no-store' }); if (!res.ok) { return { ok: false, requests: null, isNetworkError: false, error: `HTTP ${res.status}`, url }; } const json = await res.json(); return { ok: true, requests: typeof json.requests === 'number' ? json.requests : null, isNetworkError: false, error: null, url, }; } catch (err) { const isAbort = err.name === 'AbortError'; const isNetworkError = !isAbort && err.name === 'TypeError'; return { ok: false, requests: null, isNetworkError, error: isAbort ? 'request timed out' : (err.message || 'network error'), url, }; } finally { clearTimeout(timeout); } } /* ========================================================================= Smart Dual-Endpoint Fetch (Primary hosted endpoint -> Local fallback) ========================================================================= */ async function fetchHealthWithFallback() { const bases = [CONFIG.primaryBaseUrl, CONFIG.fallbackBaseUrl].filter(Boolean); let lastResult = null; for (const base of bases) { const url = `${base}${CONFIG.healthPath}`; const result = await fetchHealthFromUrl(url); if (result.ok) { state.activeBaseUrl = base; return result; } lastResult = result; } return lastResult; } async function fetchMetricsWithFallback() { const base = state.activeBaseUrl || CONFIG.primaryBaseUrl; const primaryUrl = `${base}${CONFIG.metricsPath}`; const primaryRes = await fetchMetricsFromUrl(primaryUrl); if (primaryRes.ok) return primaryRes; const fallbackBase = base === CONFIG.primaryBaseUrl ? CONFIG.fallbackBaseUrl : CONFIG.primaryBaseUrl; if (fallbackBase) { const fallbackUrl = `${fallbackBase}${CONFIG.metricsPath}`; const fallbackRes = await fetchMetricsFromUrl(fallbackUrl); if (fallbackRes.ok) return fallbackRes; } return primaryRes; } async function refreshFromSources() { const [healthRes, metricsRes] = await Promise.all([ fetchHealthWithFallback(), fetchMetricsWithFallback(), ]); const anyNetworkError = healthRes.isNetworkError || metricsRes.isNetworkError; if (anyNetworkError) showNetworkWarning(); else hideNetworkWarning(); // Push live check result into history state.history.push(healthRes); if (state.history.length > CONFIG.maxHistoryPoints) { state.history.shift(); } // Update uptime bucket tracking state.uptimeBuckets.push({ state: healthRes.ok ? 'ok' : 'down' }); if (state.uptimeBuckets.length > CONFIG.uptimeBuckets) { state.uptimeBuckets.shift(); } // Push new event state.events.unshift({ time: healthRes.timestamp, level: healthRes.ok ? 'ok' : 'bad', message: healthRes.ok ? `Health check passed — ${healthRes.latencyMs} ms (${healthRes.url})` : `Health check failed — ${healthRes.error}`, }); if (state.events.length > 50) { state.events.pop(); } // Update success & failed check counts const totalChecks = state.history.length; const successChecks = state.history.filter(h => h.ok).length; const failedChecks = totalChecks - successChecks; state.totals.success = successChecks; state.totals.failed = failedChecks; if (metricsRes.ok && typeof metricsRes.requests === 'number') { const cumulativeTotal = updateStoredTotalRequests(metricsRes.requests); state.totals.total = cumulativeTotal; state.totals.totalSource = 'metrics'; state.metricsError = null; } else { const cached = getStoredTotalRequests(); if (cached > 0) { state.totals.total = cached; } state.metricsError = metricsRes.error || 'unavailable'; } } /* ========================================================================= Formatting helpers ========================================================================= */ const fmtTime = (d) => d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const fmtShortTime = (d) => d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); const fmtPct = (n) => `${n.toFixed(2)}%`; /* ========================================================================= Render — KPI Cards ========================================================================= */ function renderKPIs() { const latest = state.history[state.history.length - 1]; const dot = document.getElementById('topStatusDot'); const indicator = document.getElementById('statusIndicator'); const statusText = document.getElementById('statusText'); const statusFoot = document.getElementById('statusFoot'); if (!latest) { indicator.className = 'status-indicator'; statusText.textContent = 'Checking…'; statusText.style.color = 'var(--text-muted)'; dot.className = 'topbar__dot'; statusFoot.textContent = 'awaiting first check'; } else { const healthy = latest.ok; indicator.className = 'status-indicator ' + (healthy ? 'is-healthy' : 'is-unhealthy'); statusText.textContent = healthy ? 'Healthy' : 'Unhealthy'; statusText.style.color = healthy ? 'var(--ok)' : 'var(--bad)'; dot.className = 'topbar__dot' + (healthy ? '' : ' is-bad'); statusFoot.textContent = `checked ${fmtTime(latest.timestamp)}`; } const setText = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; }; setText('responseTimeValue', latest ? `${latest.latencyMs} ms` : '—'); const avgRecent = average(state.history.slice(-10).map(h => h.latencyMs)); setText('responseTimeFoot', state.history.length ? `avg last 10: ${Math.round(avgRecent)} ms` : 'current'); setText('totalRequestsValue', state.totals.total.toLocaleString()); setText('totalRequestsFoot', state.totals.totalSource === 'metrics' ? 'reported by /metrics' : `metrics: ${state.metricsError || 'unavailable'}`); setText('successValue', state.totals.success.toLocaleString()); setText('failedValue', state.totals.failed.toLocaleString()); const checksTotal = state.totals.success + state.totals.failed; const successRate = checksTotal ? (state.totals.success / checksTotal) * 100 : 100; const failRate = checksTotal ? (state.totals.failed / checksTotal) * 100 : 0; setText('successRateFoot', fmtPct(successRate) + ' of checks'); setText('failedRateFoot', fmtPct(failRate) + ' of checks'); setText('lastCheckValue', latest ? fmtTime(latest.timestamp) : '—'); setText('lastCheckFoot', latest ? (latest.ok ? `HTTP ${latest.httpCode}` : `HTTP ${latest.httpCode ?? '—'} · ${latest.error}`) : '—'); } function average(arr) { if (!arr.length) return 0; return arr.reduce((a, b) => a + b, 0) / arr.length; } /* ========================================================================= Render — Latest Health Check Panel ========================================================================= */ function renderLatestCheck() { const latest = state.history[state.history.length - 1]; const set = (id, text, cls) => { const el = document.getElementById(id); if (el) { el.textContent = text; el.className = cls ? 'dd-' + cls : ''; } }; if (!latest) return; set('lcEndpoint', latest.url || `${state.activeBaseUrl}${CONFIG.healthPath}`); set('lcStatus', latest.ok ? 'healthy' : 'unhealthy', latest.ok ? 'ok' : 'bad'); set('lcCode', latest.httpCode ?? '—'); set('lcLatency', `${latest.latencyMs} ms`); set('lcTime', latest.timestamp.toLocaleString()); set('lcRaw', latest.raw || latest.error || '—'); } /* ========================================================================= Render — Events Log ========================================================================= */ function renderEvents() { const list = document.getElementById('eventsLog'); if (!list) return; list.innerHTML = ''; state.events.slice(0, 25).forEach(ev => { const li = document.createElement('li'); li.className = 'events-log__row'; li.innerHTML = ` ${fmtShortTime(ev.time)} ${ev.message} `; list.appendChild(li); }); const meta = document.getElementById('eventsMeta'); if (meta) meta.textContent = `${state.events.length} checks logged`; } /* ========================================================================= Render — Uptime Strip & Percentage ========================================================================= */ function renderUptime() { const strip = document.getElementById('uptimeStrip'); if (!strip) return; strip.innerHTML = ''; const buckets = state.uptimeBuckets; const padding = Math.max(CONFIG.uptimeBuckets - buckets.length, 0); for (let i = 0; i < padding; i++) { const cell = document.createElement('div'); cell.className = 'uptime-cell is-unknown'; strip.appendChild(cell); } buckets.forEach(b => { const cell = document.createElement('div'); cell.className = 'uptime-cell' + (b.state === 'ok' ? '' : ` is-${b.state}`); strip.appendChild(cell); }); const okCount = buckets.filter(b => b.state === 'ok').length; const pct = buckets.length ? (okCount / buckets.length) * 100 : 0; const elPct = document.getElementById('uptimePct'); if (elPct) elPct.textContent = buckets.length ? fmtPct(pct) : '—'; const meta = document.getElementById('uptimeWindowMeta'); if (meta) meta.textContent = `${buckets.length} live check${buckets.length === 1 ? '' : 's'} recorded`; const now = new Date(); const start = new Date(now.getTime() - CONFIG.uptimeBuckets * CONFIG.autoRefreshMs); const elStart = document.getElementById('uptimeTickStart'); if (elStart) elStart.textContent = buckets.length ? fmtShortTime(start) : 'no data yet'; } /* ========================================================================= Canvas Charts — Shared Setup ========================================================================= */ function prepCanvas(canvas) { if (!canvas) return null; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const w = Math.max(rect.width, 10); const h = Math.max(rect.height, 10); canvas.width = w * dpr; canvas.height = h * dpr; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); return { ctx, w, h }; } const CSSVAR = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); function drawChartPlaceholder(ctx, w, h, message) { ctx.fillStyle = CSSVAR('--text-dim'); ctx.font = '12px Outfit, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(message, w / 2, h / 2); } /* ---- Line chart: Response Time over recent health checks ---- */ function drawResponseTimeChart() { const canvas = document.getElementById('responseTimeChart'); if (!canvas) return; const prep = prepCanvas(canvas); if (!prep) return; const { ctx, w, h } = prep; const data = state.history; if (!data.length) { drawChartPlaceholder(ctx, w, h, 'Collecting data…'); return; } const padL = 34, padR = 10, padT = 12, padB = 20; const plotW = w - padL - padR; const plotH = h - padT - padB; const values = data.map(d => d.latencyMs); const maxV = Math.max(...values) * 1.15; const minV = 0; const xFor = (i) => padL + (data.length === 1 ? plotW / 2 : (i / (data.length - 1)) * plotW); const yFor = (v) => padT + plotH - ((v - minV) / (maxV - minV || 1)) * plotH; // Gridlines + y-axis labels ctx.strokeStyle = CSSVAR('--border-soft'); ctx.fillStyle = CSSVAR('--text-dim'); ctx.font = '10px IBM Plex Mono, monospace'; ctx.textBaseline = 'middle'; const gridLines = 4; for (let i = 0; i <= gridLines; i++) { const v = (maxV / gridLines) * i; const y = yFor(v); ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.lineWidth = 1; ctx.stroke(); ctx.textAlign = 'right'; ctx.fillText(Math.round(v), padL - 8, y); } // x-axis labels (first / mid / last) ctx.textAlign = 'center'; ctx.textBaseline = 'top'; [0, Math.floor((data.length - 1) / 2), data.length - 1].forEach(i => { if (i < 0 || i >= data.length) return; ctx.fillText(fmtShortTime(data[i].timestamp), xFor(i), h - padB + 6); }); // Gradient area fill const grad = ctx.createLinearGradient(0, padT, 0, padT + plotH); grad.addColorStop(0, 'rgba(91,157,249,0.28)'); grad.addColorStop(1, 'rgba(91,157,249,0.0)'); ctx.beginPath(); data.forEach((d, i) => { const x = xFor(i), y = yFor(d.latencyMs); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.lineTo(xFor(data.length - 1), padT + plotH); ctx.lineTo(xFor(0), padT + plotH); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); // Line ctx.beginPath(); data.forEach((d, i) => { const x = xFor(i), y = yFor(d.latencyMs); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = CSSVAR('--info'); ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke(); // Data points data.forEach((d, i) => { const x = xFor(i), y = yFor(d.latencyMs); ctx.beginPath(); ctx.arc(x, y, d.ok ? 2.2 : 3.4, 0, Math.PI * 2); ctx.fillStyle = d.ok ? CSSVAR('--info') : CSSVAR('--bad'); ctx.fill(); }); } /* ---- Status timeline: pass/fail history ---- */ function renderStatusTimeline() { const strip = document.getElementById('statusTimeline'); if (!strip) return; const data = state.history; strip.innerHTML = ''; const passEl = document.getElementById('statusPassCount'); const failEl = document.getElementById('statusFailCount'); const tickStart = document.getElementById('statusTickStart'); const passCount = data.filter(d => d.ok).length; const failCount = data.length - passCount; if (passEl) passEl.textContent = passCount.toLocaleString(); if (failEl) failEl.textContent = failCount.toLocaleString(); if (!data.length) { if (tickStart) tickStart.textContent = 'no data yet'; return; } const padding = Math.max(CONFIG.maxHistoryPoints - data.length, 0); for (let i = 0; i < padding; i++) { const cell = document.createElement('div'); cell.className = 'status-timeline__cell is-pending'; cell.title = 'Awaiting check'; cell.setAttribute('aria-label', 'Awaiting check'); strip.appendChild(cell); } data.forEach((d) => { const cell = document.createElement('div'); cell.className = 'status-timeline__cell' + (d.ok ? '' : ' is-fail'); const label = d.ok ? 'Healthy' : 'Unhealthy'; const tip = `${fmtTime(d.timestamp)} · ${label} · ${d.latencyMs} ms`; cell.title = tip; cell.setAttribute('aria-label', tip); cell.tabIndex = 0; strip.appendChild(cell); }); if (tickStart) tickStart.textContent = fmtShortTime(data[0].timestamp); } /* ---- Doughnut chart: Success vs Failure Ratio ---- */ function drawDoughnut() { const canvas = document.getElementById('statsDoughnut'); if (!canvas) return; const prep = prepCanvas(canvas); if (!prep) return; const { ctx, w, h } = prep; const { success, failed } = state.totals; const total = success + failed; const cx = w / 2, cy = h / 2; const radius = Math.min(w, h) / 2 - 8; const thickness = radius * 0.34; if (!total) { ctx.strokeStyle = CSSVAR('--border-soft'); ctx.lineWidth = thickness; ctx.beginPath(); ctx.arc(cx, cy, radius - thickness / 2, 0, Math.PI * 2); ctx.stroke(); drawChartPlaceholder(ctx, w, h, 'No checks yet'); return; } const successFrac = success / total; const segments = [ { frac: successFrac, color: CSSVAR('--ok') }, { frac: 1 - successFrac, color: CSSVAR('--bad') }, ]; let start = -Math.PI / 2; segments.forEach(seg => { if (seg.frac <= 0) return; const end = start + seg.frac * Math.PI * 2; ctx.beginPath(); ctx.arc(cx, cy, radius - thickness / 2, start, end); ctx.lineWidth = thickness; ctx.strokeStyle = seg.color; ctx.lineCap = segments.length > 1 && seg.frac < 1 ? 'butt' : 'round'; ctx.stroke(); start = end; }); const centerVal = document.getElementById('doughnutCenterValue'); if (centerVal) centerVal.textContent = fmtPct(successFrac * 100); } /* ========================================================================= Network/CORS Warning Banner ========================================================================= */ function showNetworkWarning() { const el = document.getElementById('networkWarning'); if (el) el.hidden = false; } function hideNetworkWarning() { const el = document.getElementById('networkWarning'); if (el) el.hidden = true; } /* ========================================================================= Master Render ========================================================================= */ function renderAll() { renderKPIs(); renderLatestCheck(); renderEvents(); renderUptime(); drawResponseTimeChart(); renderStatusTimeline(); drawDoughnut(); const elUpdated = document.getElementById('lastUpdated'); if (elUpdated) elUpdated.textContent = new Date().toLocaleTimeString(); } /* ========================================================================= Refresh Flow ========================================================================= */ async function doRefresh() { if (state.isChecking) return; state.isChecking = true; const btn = document.getElementById('refreshBtn'); btn.classList.add('is-spinning'); btn.disabled = true; try { await refreshFromSources(); renderAll(); } finally { state.isChecking = false; btn.classList.remove('is-spinning'); btn.disabled = false; } } /* ========================================================================= Init ========================================================================= */ function init() { renderAll(); // Initial empty state paint document.getElementById('refreshBtn').addEventListener('click', doRefresh); window.addEventListener('resize', debounce(renderAll, 150)); doRefresh(); // Trigger first live check setInterval(doRefresh, CONFIG.autoRefreshMs); } function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } document.addEventListener('DOMContentLoaded', init);