الملفات
GHyamah-Test/q5-mithal-dashboard/dashboard/index.html
2026-07-26 19:45:13 +03:00

474 أسطر
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>mithal.space — Monitoring Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--border: #262d38;
--text: #e6edf3;
--muted: #8b949e;
--up: #3fb950;
--up-dim: rgba(63,185,80,.14);
--down: #f85149;
--down-dim: rgba(248,81,73,.14);
--warn: #d29922;
--warn-dim: rgba(210,153,34,.14);
--accent: #58a6ff;
--accent-2: #bc8cff;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 32px 24px 56px;
background: var(--bg);
color: var(--text);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1120px; margin: 0 auto; }
header {
display: flex; flex-wrap: wrap; gap: 12px;
align-items: baseline; justify-content: space-between;
margin-bottom: 26px;
}
h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -.01em; }
h1 a { color: inherit; text-decoration: none; border-bottom: 1px solid var(--border); }
h1 a:hover { border-bottom-color: var(--accent); }
h1 span { color: var(--muted); font-weight: 400; }
.updated { font-size: 13px; color: var(--muted); }
.updated b { color: var(--text); font-weight: 500; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.label {
font-size: 12px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin-bottom: 10px;
}
.value {
font-size: 30px; font-weight: 650; letter-spacing: -.02em;
font-variant-numeric: tabular-nums;
}
.sub { margin-top: 6px; font-size: 13px; color: var(--muted); }
.badge {
display: inline-flex; align-items: center; gap: 9px;
padding: 8px 16px; border-radius: 999px;
font-size: 19px; font-weight: 700; letter-spacing: .04em;
align-self: flex-start;
}
.badge .dot { width: 10px; height: 10px; border-radius: 50%; background: currentColor; }
.badge.up { color: var(--up); background: var(--up-dim); border: 1px solid rgba(63,185,80,.35); }
.badge.down { color: var(--down); background: var(--down-dim); border: 1px solid rgba(248,81,73,.35); }
.badge.unknown { color: var(--muted); background: #1c2129; border: 1px solid var(--border); font-size: 16px; }
.badge.up .dot { animation: pulse 2.2s ease-out infinite; }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(63,185,80,.55); }
70% { box-shadow: 0 0 0 9px rgba(63,185,80,0); }
100% { box-shadow: 0 0 0 0 rgba(63,185,80,0); }
}
/* SSL card colour states */
.ssl-ok .value { color: var(--up); }
.ssl-warn .value { color: var(--warn); }
.ssl-crit .value { color: var(--down); }
.ssl-pill {
display: inline-block; margin-top: 8px; padding: 3px 10px;
border-radius: 999px; font-size: 12px; font-weight: 600;
}
.ssl-ok .ssl-pill { color: var(--up); background: var(--up-dim); }
.ssl-warn .ssl-pill { color: var(--warn); background: var(--warn-dim); }
.ssl-crit .ssl-pill { color: var(--down); background: var(--down-dim); }
.chart-card { padding: 20px 20px 12px; }
.chart-head {
display: flex; align-items: baseline; justify-content: space-between;
gap: 12px; flex-wrap: wrap; margin-bottom: 6px;
}
.chart-head .label { margin: 0; }
.legend { display: flex; gap: 16px; font-size: 12.5px; color: var(--muted); margin-bottom: 12px; }
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; }
.chart-box { position: relative; height: 300px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td {
text-align: left; padding: 10px 12px;
border-bottom: 1px solid var(--border);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
th {
font-size: 11.5px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); font-weight: 600;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: rgba(255,255,255,.02); }
td.num { color: var(--text); }
td.null { color: var(--muted); }
.code-ok { color: var(--up); }
.code-bad { color: var(--down); }
.empty {
display: flex; align-items: center; justify-content: center;
height: 100%; min-height: 120px; padding: 24px;
color: var(--muted); font-size: 14px; text-align: center;
border: 1px dashed var(--border); border-radius: 10px;
}
.banner {
display: none; margin-bottom: 16px; padding: 12px 16px;
border-radius: 10px; border: 1px solid rgba(210,153,34,.35);
background: rgba(210,153,34,.12); color: var(--warn); font-size: 13.5px;
}
.banner.show { display: block; }
.banner code, footer code {
background: rgba(255,255,255,.06); padding: 1px 5px; border-radius: 4px;
font-size: 12.5px; color: var(--text);
}
footer {
margin-top: 24px; font-size: 12.5px; color: var(--muted);
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
}
footer code.path { color: var(--accent); background: none; padding: 0; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1><a href="https://mithal.space" target="_blank" rel="noopener">mithal.space</a> <span>· monitoring</span></h1>
<div class="updated">Last updated <b id="updated"></b></div>
</header>
<div id="banner" class="banner"></div>
<div class="grid">
<div class="card">
<div class="label">Current status</div>
<div id="badge" class="badge unknown"><span class="dot"></span><span id="badgeText">NO DATA</span></div>
<div class="sub" id="statusSub">Waiting for the first collection…</div>
</div>
<div class="card">
<div class="label">Uptime — last 24h</div>
<div class="value" id="uptime"></div>
<div class="sub" id="uptimeSub">— up / — checks</div>
</div>
<div class="card" id="sslCard">
<div class="label">TLS certificate</div>
<div class="value" id="ssl"></div>
<div class="sub">days remaining</div>
<div class="ssl-pill" id="sslPill" style="display:none"></div>
</div>
<div class="card">
<div class="label">Latest response</div>
<div class="value" id="latency"></div>
<div class="sub" id="latencySub">search — · dns —</div>
</div>
</div>
<div class="card chart-card">
<div class="chart-head">
<div class="label">Response time — last hour</div>
<div class="updated" id="chartRange"></div>
</div>
<div class="legend">
<span><i style="background:#58a6ff"></i>page latency</span>
<span><i style="background:#bc8cff"></i>search latency</span>
</div>
<div class="chart-box">
<canvas id="chart"></canvas>
<div id="chartEmpty" class="empty" style="display:none">No checks in the last hour.</div>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="label">Last 10 checks</div>
<div style="overflow-x:auto">
<table>
<thead>
<tr>
<th>Time</th><th>Status</th><th>Code</th>
<th>Latency</th><th>DNS</th><th>Search</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<div id="tableEmpty" class="empty" style="display:none">No checks recorded yet.</div>
</div>
<footer>
<span>Data source: <code class="path" id="srcPath"></code></span>
<span>Auto-refresh every 60s · 48h retention</span>
</footer>
</div>
<script>
// ---------------------------------------------------------------------------
// Path to the JSON array written by collector/collect.py.
const DATA_URL = 'data/metrics.json';
const REFRESH_MS = 60000;
const CHART_WINDOW_MS = 60 * 60 * 1000; // chart shows the last hour
const UPTIME_WINDOW_MS = 24 * 60 * 60 * 1000; // uptime % over the last 24h
// ---------------------------------------------------------------------------
const $ = (id) => document.getElementById(id);
$('srcPath').textContent = DATA_URL;
let chart = null;
const isNum = (v) => typeof v === 'number' && isFinite(v);
function fmtTime(iso) {
const d = new Date(iso);
return isNaN(d) ? String(iso ?? '—')
: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function fmtDateTime(iso) {
const d = new Date(iso);
return isNaN(d) ? String(iso ?? '—')
: d.toLocaleString([], { month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function fmtMs(v) { return isNum(v) ? `${Math.round(v)} ms` : '—'; }
function showBanner(html) { const b = $('banner'); b.innerHTML = html; b.classList.add('show'); }
function hideBanner() { $('banner').classList.remove('show'); }
function renderEmpty(reason) {
$('badge').className = 'badge unknown';
$('badgeText').textContent = 'NO DATA';
$('statusSub').textContent = reason || 'Waiting for the first collection…';
$('uptime').textContent = '—';
$('uptimeSub').textContent = '— up / — checks';
$('ssl').textContent = '—';
$('sslCard').className = 'card';
$('sslPill').style.display = 'none';
$('latency').textContent = '—';
$('latencySub').textContent = 'search — · dns —';
$('rows').innerHTML = '';
$('tableEmpty').style.display = 'flex';
$('chartRange').textContent = '';
$('chartEmpty').textContent = 'No data yet — start the collector to gather metrics.';
$('chartEmpty').style.display = 'flex';
$('chart').style.display = 'none';
if (chart) { chart.destroy(); chart = null; }
}
function renderChart(points) {
if (points.length === 0) {
$('chartEmpty').textContent = 'No checks in the last hour.';
$('chartEmpty').style.display = 'flex';
$('chart').style.display = 'none';
if (chart) { chart.destroy(); chart = null; }
return;
}
$('chartEmpty').style.display = 'none';
$('chart').style.display = 'block';
const labels = points.map(p => fmtTime(p.ts));
// Failed checks carry null, which draws a gap rather than a misleading zero.
const latency = points.map(p => (isNum(p.latency_ms) ? p.latency_ms : null));
const search = points.map(p => (isNum(p.search_ms) ? p.search_ms : null));
const pointColors = points.map(p => (p.up ? '#58a6ff' : '#f85149'));
if (chart) {
chart.data.labels = labels;
chart.data.datasets[0].data = latency;
chart.data.datasets[0].pointBackgroundColor = pointColors;
chart.data.datasets[1].data = search;
chart.update('none');
return;
}
const ctx = $('chart').getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 300);
grad.addColorStop(0, 'rgba(88,166,255,.26)');
grad.addColorStop(1, 'rgba(88,166,255,0)');
chart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'page latency (ms)', data: latency,
borderColor: '#58a6ff', backgroundColor: grad,
borderWidth: 2, pointRadius: 2.5, pointHoverRadius: 5,
pointBackgroundColor: pointColors, pointBorderWidth: 0,
tension: .3, fill: true, spanGaps: false,
},
{
label: 'search latency (ms)', data: search,
borderColor: '#bc8cff', backgroundColor: 'transparent',
borderWidth: 2, borderDash: [5, 4],
pointRadius: 2, pointHoverRadius: 5, pointBackgroundColor: '#bc8cff',
pointBorderWidth: 0, tension: .3, fill: false, spanGaps: false,
}
]
},
options: {
responsive: true, maintainAspectRatio: false, animation: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#1c2129', borderColor: '#262d38', borderWidth: 1,
titleColor: '#e6edf3', bodyColor: '#8b949e', padding: 10,
callbacks: {
label: (c) => c.parsed.y === null
? `${c.dataset.label.split(' ')[0]}: failed`
: `${c.dataset.label.split(' ')[0]}: ${c.parsed.y.toFixed(0)} ms`
}
}
},
scales: {
x: { grid: { color: 'rgba(255,255,255,.05)' },
ticks: { color: '#8b949e', maxTicksLimit: 10, maxRotation: 0, autoSkip: true } },
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,.05)' },
ticks: { color: '#8b949e', callback: (v) => v + ' ms' } }
}
}
});
}
function renderTable(records) {
const last10 = records.slice(-10).reverse();
const tbody = $('rows');
if (last10.length === 0) {
tbody.innerHTML = '';
$('tableEmpty').style.display = 'flex';
return;
}
$('tableEmpty').style.display = 'none';
tbody.innerHTML = last10.map(r => {
const cell = (v) => isNum(v)
? `<td class="num">${Math.round(v)} ms</td>`
: `<td class="null">—</td>`;
const code = r.code == null
? '<td class="null">—</td>'
: `<td class="${r.up ? 'code-ok' : 'code-bad'}">${r.code}</td>`;
return `<tr>
<td class="null">${fmtTime(r.ts)}</td>
<td>${r.up ? '✅' : '❌'}</td>
${code}
${cell(r.latency_ms)}
${cell(r.dns_ms)}
${cell(r.search_ms)}
</tr>`;
}).join('');
}
function renderSSL(records) {
// Use the most recent check that actually managed to read the certificate.
let days = null;
for (let i = records.length - 1; i >= 0; i--) {
if (isNum(records[i].ssl_days_left)) { days = records[i].ssl_days_left; break; }
}
const card = $('sslCard');
const pill = $('sslPill');
if (days === null) {
$('ssl').textContent = '—';
card.className = 'card';
pill.style.display = 'none';
return;
}
$('ssl').textContent = days;
let state, text;
if (days > 30) { state = 'ssl-ok'; text = 'Healthy'; }
else if (days >= 8) { state = 'ssl-warn'; text = 'Renew soon'; }
else { state = 'ssl-crit'; text = days <= 0 ? 'Expired' : 'Expiring'; }
card.className = 'card ' + state;
pill.className = 'ssl-pill';
pill.textContent = text;
pill.style.display = 'inline-block';
}
function render(raw) {
if (!Array.isArray(raw) || raw.length === 0) { renderEmpty('No checks recorded yet.'); return; }
const records = raw.filter(r => r && typeof r === 'object' && r.ts);
if (records.length === 0) { renderEmpty('Data file contains no usable checks.'); return; }
const latest = records[records.length - 1];
const now = Date.now();
// Status badge
$('badge').className = 'badge ' + (latest.up ? 'up' : 'down');
$('badgeText').textContent = latest.up ? 'UP' : 'DOWN';
$('statusSub').textContent =
(latest.code != null ? `HTTP ${latest.code}` : 'no response') + ' · ' + fmtDateTime(latest.ts);
// Uptime % over the last 24h
const window24 = records.filter(r => now - new Date(r.ts).getTime() <= UPTIME_WINDOW_MS);
if (window24.length > 0) {
const up = window24.filter(r => r.up === true).length;
$('uptime').textContent = ((up / window24.length) * 100).toFixed(1) + '%';
$('uptimeSub').textContent = `${up} up / ${window24.length} checks`;
} else {
$('uptime').textContent = '—';
$('uptimeSub').textContent = 'no checks in the last 24h';
}
// Latest response times
$('latency').textContent = fmtMs(latest.latency_ms);
$('latencySub').textContent = `search ${fmtMs(latest.search_ms)} · dns ${fmtMs(latest.dns_ms)}`;
renderSSL(records);
const hour = records.filter(r => now - new Date(r.ts).getTime() <= CHART_WINDOW_MS);
$('chartRange').textContent = hour.length > 1
? `${fmtTime(hour[0].ts)}${fmtTime(hour[hour.length - 1].ts)} · ${hour.length} checks`
: (hour.length === 1 ? `${fmtTime(hour[0].ts)} · 1 check` : '');
renderChart(hour);
renderTable(records);
}
async function load() {
try {
const res = await fetch(`${DATA_URL}?t=${Date.now()}`, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
hideBanner();
render(data);
} catch (err) {
// Missing file, invalid JSON, or a file:// fetch block — degrade, never crash.
renderEmpty('Could not load metrics data.');
showBanner(
`Could not read <code>${DATA_URL}</code> (${err.message}). ` +
`Run the collector to create it, and serve this folder over HTTP ` +
`(<code>python -m http.server 8080</code> from <code>dashboard/</code>) ` +
`— opening the file with <code>file://</code> blocks the fetch.`
);
}
$('updated').textContent = new Date().toLocaleTimeString();
}
load();
setInterval(load, REFRESH_MS);
</script>
</body>
</html>