feat: add monitoring dashboard and metrics collector

هذا الالتزام موجود في:
2026-07-29 05:52:34 +03:00
الأصل 32aa897442
التزام 8a82a97c35
3 ملفات معدلة مع 771 إضافات و0 حذوفات

عرض الملف

@@ -0,0 +1,437 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mithal Monitor Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--bg: #0f172a;
--bg-soft: #111c38;
--card: rgba(15, 23, 42, 0.88);
--card-border: rgba(148, 163, 184, 0.18);
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
--good: #22c55e;
--bad: #ef4444;
--warn: #f59e0b;
--shadow: 0 18px 60px rgba(2, 6, 23, 0.45);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(56, 189, 248, 0.18), transparent 34%),
radial-gradient(circle at top right, rgba(34, 197, 94, 0.14), transparent 28%),
linear-gradient(180deg, #020617 0%, var(--bg) 45%, #020617 100%);
min-height: 100vh;
}
.page {
max-width: 1280px;
margin: 0 auto;
padding: 32px 20px 48px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: end;
margin-bottom: 24px;
flex-wrap: wrap;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
font-size: 12px;
margin-bottom: 8px;
}
h1 {
margin: 0;
font-size: clamp(2rem, 5vw, 3.6rem);
line-height: 1;
}
.subhead {
margin-top: 10px;
color: var(--muted);
max-width: 680px;
}
.status-pill {
padding: 10px 14px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.7);
border: 1px solid var(--card-border);
color: var(--muted);
box-shadow: var(--shadow);
white-space: nowrap;
}
.grid {
display: grid;
gap: 18px;
}
.metrics-grid {
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
margin-bottom: 18px;
}
.card {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 20px;
padding: 18px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.metric-label {
color: var(--muted);
font-size: 13px;
margin-bottom: 10px;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
line-height: 1.1;
}
.metric-note {
margin-top: 8px;
color: var(--muted);
font-size: 13px;
min-height: 18px;
}
.good { color: var(--good); }
.bad { color: var(--bad); }
.warn { color: var(--warn); }
.panel-title {
margin: 0 0 14px;
font-size: 18px;
}
.chart-wrap {
height: 380px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 12px 10px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
vertical-align: top;
font-size: 14px;
}
th {
color: var(--muted);
font-weight: 600;
}
.table-scroll {
overflow-x: auto;
}
.small {
color: var(--muted);
font-size: 13px;
}
.footer-note {
margin-top: 14px;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 720px) {
.page { padding: 18px 14px 34px; }
.card { padding: 16px; }
.metric-value { font-size: 1.6rem; }
.chart-wrap { height: 320px; }
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<div>
<div class="eyebrow">Mithal.space monitoring</div>
<h1>Live uptime and latency dashboard</h1>
<p class="subhead">This page reads <strong>metrics.json</strong>, summarizes the last 24 hours, and refreshes automatically every 60 seconds.</p>
</div>
<div class="status-pill" id="lastRefresh">Waiting for data...</div>
</section>
<section class="grid metrics-grid">
<div class="card">
<div class="metric-label">Uptime percentage (24h)</div>
<div class="metric-value" id="uptimePercentage">--</div>
<div class="metric-note" id="uptimeNote"></div>
</div>
<div class="card">
<div class="metric-label">Latest HTTP latency</div>
<div class="metric-value" id="latestLatency">--</div>
<div class="metric-note" id="latencyNote"></div>
</div>
<div class="card">
<div class="metric-label">Latest DNS lookup time</div>
<div class="metric-value" id="latestDns">--</div>
<div class="metric-note" id="dnsNote"></div>
</div>
<div class="card">
<div class="metric-label">SSL status</div>
<div class="metric-value" id="sslStatus">--</div>
<div class="metric-note" id="sslNote"></div>
</div>
<div class="card">
<div class="metric-label">SSL expiration date</div>
<div class="metric-value" id="sslExpiry">--</div>
<div class="metric-note" id="sslExpiryNote"></div>
</div>
<div class="card">
<div class="metric-label">Latest search response time</div>
<div class="metric-value" id="latestSearch">--</div>
<div class="metric-note" id="searchNote"></div>
</div>
</section>
<section class="card" style="margin-bottom: 18px;">
<h2 class="panel-title">Latency line chart</h2>
<div class="chart-wrap">
<canvas id="latencyChart"></canvas>
</div>
</section>
<section class="card">
<h2 class="panel-title">Latest 10 monitoring records</h2>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Status</th>
<th>Uptime</th>
<th>Latency</th>
<th>DNS</th>
<th>SSL</th>
<th>SSL Expiry</th>
<th>Search</th>
</tr>
</thead>
<tbody id="recordsBody"></tbody>
</table>
</div>
<div class="footer-note" id="errorNote"></div>
</section>
</main>
<script>
const metricsPath = 'metrics.json';
let latencyChart = null;
function safeNumber(value, digits = 2) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return '--';
}
return `${Number(value).toFixed(digits)} ms`;
}
function formatTimestamp(timestamp) {
if (!timestamp) {
return '--';
}
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return timestamp;
}
return date.toLocaleString();
}
function formatBoolean(value) {
if (value === true) {
return 'UP';
}
if (value === false) {
return 'DOWN';
}
return '--';
}
function setMetric(id, value, unit = 'ms', noteId = null, error = null) {
const valueEl = document.getElementById(id);
const noteEl = noteId ? document.getElementById(noteId) : null;
if (!valueEl) {
return;
}
if (value === null || value === undefined) {
valueEl.textContent = '--';
valueEl.className = 'metric-value warn';
} else if (unit === 'bool') {
valueEl.textContent = formatBoolean(value);
valueEl.className = `metric-value ${value ? 'good' : 'bad'}`;
} else if (unit === 'text') {
valueEl.textContent = String(value);
valueEl.className = 'metric-value';
} else {
valueEl.textContent = safeNumber(value);
valueEl.className = 'metric-value';
}
if (noteEl) {
noteEl.textContent = error || '';
}
}
function calculateUptimePercentage(records) {
const lastDay = Date.now() - (24 * 60 * 60 * 1000);
const recentRecords = records.filter((record) => {
const time = new Date(record.timestamp).getTime();
return !Number.isNaN(time) && time >= lastDay;
});
if (!recentRecords.length) {
return { percentage: null, count: 0 };
}
const successful = recentRecords.filter((record) => record.uptime === true).length;
return {
percentage: Math.round((successful / recentRecords.length) * 1000) / 10,
count: recentRecords.length,
};
}
function buildChart(records) {
const canvas = document.getElementById('latencyChart');
const labels = records.map((record) => formatTimestamp(record.timestamp));
const data = records.map((record) => record.latency_ms ?? null);
if (latencyChart) {
latencyChart.destroy();
}
latencyChart = new Chart(canvas, {
type: 'line',
data: {
labels,
datasets: [{
label: 'HTTP latency (ms)',
data,
tension: 0.35,
borderColor: '#38bdf8',
backgroundColor: 'rgba(56, 189, 248, 0.15)',
pointRadius: 3,
pointHoverRadius: 5,
fill: true,
spanGaps: true,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#e2e8f0' },
},
},
scales: {
x: {
ticks: { color: '#94a3b8', maxRotation: 0, autoSkip: true },
grid: { color: 'rgba(148, 163, 184, 0.12)' },
},
y: {
ticks: { color: '#94a3b8' },
grid: { color: 'rgba(148, 163, 184, 0.12)' },
},
},
},
});
}
function renderTable(records) {
const body = document.getElementById('recordsBody');
body.innerHTML = '';
const latestRecords = records.slice(-10).reverse();
if (!latestRecords.length) {
body.innerHTML = '<tr><td colspan="8" class="small">No records available yet.</td></tr>';
return;
}
for (const record of latestRecords) {
const row = document.createElement('tr');
row.innerHTML = `
<td>${formatTimestamp(record.timestamp)}</td>
<td>${record.status_code ?? '--'}</td>
<td class="${record.uptime === true ? 'good' : record.uptime === false ? 'bad' : 'warn'}">${formatBoolean(record.uptime)}</td>
<td>${record.latency_ms ?? '--'}${record.latency_ms === null && record.latency_ms_error ? `<div class="small">${record.latency_ms_error}</div>` : ''}</td>
<td>${record.dns_lookup_ms ?? '--'}${record.dns_lookup_ms === null && record.dns_lookup_ms_error ? `<div class="small">${record.dns_lookup_ms_error}</div>` : ''}</td>
<td class="${record.ssl_valid === true ? 'good' : record.ssl_valid === false ? 'bad' : 'warn'}">${formatBoolean(record.ssl_valid)}</td>
<td>${record.ssl_expiry ?? '--'}${record.ssl_expiry === null && record.ssl_expiry_error ? `<div class="small">${record.ssl_expiry_error}</div>` : ''}</td>
<td>${record.search_response_ms ?? '--'}${record.search_response_ms === null && record.search_response_ms_error ? `<div class="small">${record.search_response_ms_error}</div>` : ''}</td>
`;
body.appendChild(row);
}
}
function renderSummary(records) {
const latest = records[records.length - 1] || {};
const uptimeSummary = calculateUptimePercentage(records);
document.getElementById('uptimePercentage').textContent = uptimeSummary.percentage === null ? '--' : `${uptimeSummary.percentage}%`;
document.getElementById('uptimeNote').textContent = uptimeSummary.count ? `${uptimeSummary.count} records in the last 24 hours` : 'No recent records';
setMetric('latestLatency', latest.latency_ms, 'ms', 'latencyNote', latest.latency_ms_error || null);
setMetric('latestDns', latest.dns_lookup_ms, 'ms', 'dnsNote', latest.dns_lookup_ms_error || null);
setMetric('sslStatus', latest.ssl_valid, 'bool', 'sslNote', latest.ssl_valid_error || null);
document.getElementById('sslExpiry').textContent = latest.ssl_expiry || '--';
document.getElementById('sslExpiryNote').textContent = latest.ssl_expiry_error || '';
setMetric('latestSearch', latest.search_response_ms, 'ms', 'searchNote', latest.search_response_ms_error || null);
document.getElementById('lastRefresh').textContent = `Last updated: ${new Date().toLocaleTimeString()}`;
}
async function loadMetrics() {
const errorNote = document.getElementById('errorNote');
try {
const response = await fetch(`${metricsPath}?t=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Failed to load metrics.json (${response.status})`);
}
const data = await response.json();
const records = Array.isArray(data) ? data : [];
renderSummary(records);
buildChart(records);
renderTable(records);
errorNote.textContent = '';
} catch (error) {
errorNote.textContent = `Unable to load metrics: ${error.message}`;
}
}
loadMetrics();
setInterval(loadMetrics, 60000);
</script>
</body>
</html>

عرض الملف

@@ -0,0 +1,34 @@
[
{
"timestamp": "2026-07-29T02:43:55.590275+00:00",
"latency_ms": 737.47,
"status_code": 200,
"uptime": true,
"ssl_valid": null,
"ssl_valid_error": "SSL certificate does not expose an expiration date",
"ssl_expiry": null,
"ssl_expiry_error": "SSL certificate does not expose an expiration date",
"dns_lookup_ms": 48.09,
"search_response_ms": 769.37
},
{
"timestamp": "2026-07-29T02:44:09.531381+00:00",
"latency_ms": 705.17,
"status_code": 200,
"uptime": true,
"ssl_valid": true,
"ssl_expiry": "2026-09-15T13:10:47+00:00",
"dns_lookup_ms": 48.28,
"search_response_ms": 763.97
},
{
"timestamp": "2026-07-29T02:46:01.686895+00:00",
"latency_ms": 708.52,
"status_code": 200,
"uptime": true,
"ssl_valid": true,
"ssl_expiry": "2026-09-15T13:10:47+00:00",
"dns_lookup_ms": 47.78,
"search_response_ms": 750.01
}
]

عرض الملف

@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Collect availability metrics for https://mithal.space every minute."""
from __future__ import annotations
import argparse
import json
import socket
import ssl
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
import tempfile
from urllib import parse, request
TARGET_URL = "https://mithal.space"
METRICS_FILE = Path(__file__).with_name("metrics.json")
USER_AGENT = "Mozilla/5.0 (compatible; MithalMonitor/1.0)"
@dataclass
class SearchConfig:
"""Discovered search request details from the homepage."""
action_url: str
method: str
query_param: str
fixed_params: dict[str, str]
class SearchFormParser(HTMLParser):
"""Extract candidate search forms from the homepage HTML."""
def __init__(self) -> None:
super().__init__()
self.forms: list[dict[str, Any]] = []
self._current_form: dict[str, Any] | None = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attributes = {key.lower(): value for key, value in attrs}
if tag.lower() == "form":
self._current_form = {
"action": attributes.get("action", ""),
"method": (attributes.get("method") or "GET").upper(),
"inputs": [],
}
return
if self._current_form is not None and tag.lower() == "input":
self._current_form["inputs"].append(
{
"type": (attributes.get("type") or "text").lower(),
"name": attributes.get("name", ""),
"value": attributes.get("value", ""),
}
)
def handle_endtag(self, tag: str) -> None:
if tag.lower() == "form" and self._current_form is not None:
self.forms.append(self._current_form)
self._current_form = None
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def http_get(url: str, timeout: int = 20) -> tuple[float, int, bytes]:
"""Perform a full HTTP GET and return latency, status code, and body."""
started_at = time.perf_counter()
req = request.Request(url, headers={"User-Agent": USER_AGENT})
with request.urlopen(req, timeout=timeout) as response:
body = response.read()
latency_ms = (time.perf_counter() - started_at) * 1000
return latency_ms, int(response.status), body
def fetch_homepage_html(base_url: str) -> str:
req = request.Request(base_url, headers={"User-Agent": USER_AGENT})
with request.urlopen(req, timeout=20) as response:
return response.read().decode("utf-8", errors="replace")
def discover_search_config(base_url: str) -> SearchConfig:
"""Inspect the homepage and derive the real search request shape."""
homepage_html = fetch_homepage_html(base_url)
parser = SearchFormParser()
parser.feed(homepage_html)
for form in parser.forms:
action = (form.get("action") or "").strip()
method = (form.get("method") or "GET").upper()
inputs = form.get("inputs") or []
query_input = None
fixed_params: dict[str, str] = {}
for input_item in inputs:
input_name = (input_item.get("name") or "").strip()
input_type = (input_item.get("type") or "text").lower()
input_value = input_item.get("value") or ""
if not input_name:
continue
if input_type in {"text", "search", "url", "email", "tel", "hidden"} and query_input is None:
if input_type != "hidden":
query_input = input_name
if input_type == "hidden" and input_value:
fixed_params[input_name] = input_value
if query_input is None:
for input_item in inputs:
input_name = (input_item.get("name") or "").strip()
input_type = (input_item.get("type") or "text").lower()
if input_name and input_type != "hidden":
query_input = input_name
break
if query_input and ("search" in action.lower() or any("search" in (item.get("name") or "").lower() for item in inputs)):
return SearchConfig(
action_url=parse.urljoin(base_url, action or "/search"),
method=method,
query_param=query_input,
fixed_params=fixed_params,
)
return SearchConfig(
action_url=parse.urljoin(base_url, "/search"),
method="GET",
query_param="q",
fixed_params={},
)
def measure_dns_lookup(hostname: str) -> float:
started_at = time.perf_counter()
socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM)
return (time.perf_counter() - started_at) * 1000
def measure_ssl_certificate(hostname: str, port: int = 443) -> tuple[bool, str]:
pem_certificate = ssl.get_server_certificate((hostname, port), timeout=20)
with tempfile.NamedTemporaryFile("w", delete=True) as temp_file:
temp_file.write(pem_certificate)
temp_file.flush()
certificate = ssl._ssl._test_decode_cert(temp_file.name) # type: ignore[attr-defined]
not_after = certificate.get("notAfter")
if not not_after:
raise ValueError("SSL certificate does not expose an expiration date")
expiry_timestamp = ssl.cert_time_to_seconds(not_after)
expiry_dt = datetime.fromtimestamp(expiry_timestamp, tz=timezone.utc)
is_valid = datetime.now(timezone.utc) < expiry_dt
return is_valid, expiry_dt.isoformat()
def measure_search_response(base_url: str, query: str = "test") -> float:
search_config = discover_search_config(base_url)
params = dict(search_config.fixed_params)
params[search_config.query_param] = query
if search_config.method != "GET":
raise ValueError(f"Unsupported search method: {search_config.method}")
search_url = f"{search_config.action_url}?{parse.urlencode(params)}"
latency_ms, _, _ = http_get(search_url)
return latency_ms
def metric_record(name: str, value: Any, error_message: str | None = None) -> dict[str, Any]:
record = {name: value}
if error_message is not None:
record[f"{name}_error"] = error_message
return record
def collect_metrics(base_url: str = TARGET_URL) -> dict[str, Any]:
hostname = parse.urlparse(base_url).hostname or "mithal.space"
record: dict[str, Any] = {"timestamp": now_iso()}
try:
latency_ms, status_code, _ = http_get(base_url)
record.update(metric_record("latency_ms", round(latency_ms, 2)))
record.update(metric_record("status_code", status_code))
record["uptime"] = status_code == 200
except Exception as exc: # noqa: BLE001 - keep the monitor resilient.
record.update(metric_record("latency_ms", None, str(exc)))
record.update(metric_record("status_code", None, str(exc)))
record["uptime"] = None
record["uptime_error"] = str(exc)
try:
ssl_valid, ssl_expiry = measure_ssl_certificate(hostname)
record["ssl_valid"] = ssl_valid
record["ssl_expiry"] = ssl_expiry
except Exception as exc: # noqa: BLE001 - keep the monitor resilient.
record["ssl_valid"] = None
record["ssl_valid_error"] = str(exc)
record["ssl_expiry"] = None
record["ssl_expiry_error"] = str(exc)
try:
dns_lookup_ms = measure_dns_lookup(hostname)
record["dns_lookup_ms"] = round(dns_lookup_ms, 2)
except Exception as exc: # noqa: BLE001 - keep the monitor resilient.
record["dns_lookup_ms"] = None
record["dns_lookup_ms_error"] = str(exc)
try:
search_response_ms = measure_search_response(base_url)
record["search_response_ms"] = round(search_response_ms, 2)
except Exception as exc: # noqa: BLE001 - keep the monitor resilient.
record["search_response_ms"] = None
record["search_response_ms_error"] = str(exc)
return record
def load_metrics(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
try:
with path.open("r", encoding="utf-8") as file_handle:
data = json.load(file_handle)
except Exception:
return []
if isinstance(data, list):
return [record for record in data if isinstance(record, dict)]
return []
def save_metrics(path: Path, records: list[dict[str, Any]]) -> None:
temp_path = path.with_suffix(".tmp")
with temp_path.open("w", encoding="utf-8") as file_handle:
json.dump(records, file_handle, ensure_ascii=False, indent=2)
file_handle.write("\n")
temp_path.replace(path)
def append_metric_record(record: dict[str, Any], path: Path = METRICS_FILE) -> None:
records = load_metrics(path)
records.append(record)
save_metrics(path, records)
def run_monitor(interval_seconds: int, once: bool, target_url: str, metrics_file: Path) -> None:
while True:
record = collect_metrics(target_url)
append_metric_record(record, metrics_file)
print(json.dumps(record, ensure_ascii=False))
if once:
return
time.sleep(interval_seconds)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Collect monitoring metrics for mithal.space")
parser.add_argument("--interval", type=int, default=60, help="Seconds between metric collections")
parser.add_argument("--once", action="store_true", help="Collect one record and exit")
parser.add_argument("--target-url", default=TARGET_URL, help="Website to monitor")
parser.add_argument("--metrics-file", default=str(METRICS_FILE), help="Path to metrics.json")
return parser.parse_args()
def main() -> int:
args = parse_args()
metrics_file = Path(args.metrics_file)
try:
run_monitor(args.interval, args.once, args.target_url, metrics_file)
except KeyboardInterrupt:
return 0
except Exception as exc: # noqa: BLE001 - the outer loop should never crash silently.
print(f"monitor failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())