690 أسطر
23 KiB
JavaScript
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 — <strong>${healthRes.latencyMs} ms</strong> (${healthRes.url})`
|
|
: `Health check failed — <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); |