الملفات
ghaymah-exam-amirkasseb-sre/q1-deploy-monitor/dashboard/script.js
2026-07-26 19:57:48 +03:00

690 أسطر
23 KiB
JavaScript

/* =========================================================================
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": <int> }
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 &mdash; <strong>${healthRes.latencyMs} ms</strong> (${healthRes.url})`
: `Health check failed &mdash; <strong>${healthRes.error}</strong>`,
});
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 = `
<span class="events-log__dot events-log__dot--${ev.level}"></span>
<span class="events-log__time">${fmtShortTime(ev.time)}</span>
<span class="events-log__msg">${ev.message}</span>
`;
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);