Complete Ghaymah cloud assessment

هذا الالتزام موجود في:
yassinelagamy
2026-07-26 19:45:13 +03:00
التزام 8bc563fa6b
27 ملفات معدلة مع 4152 إضافات و0 حذوفات

عرض الملف

@@ -0,0 +1,41 @@
FROM python:3.12-slim
# curl is used by the HEALTHCHECK below.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PORT=8080 \
WEB_ROOT=/app/dashboard \
METRICS_FILE=/app/dashboard/data/metrics.json
WORKDIR /app
# Dependencies first so editing the collector or dashboard doesn't reinstall them.
COPY collector/requirements.txt ./collector/requirements.txt
RUN pip install --no-cache-dir -r collector/requirements.txt
# Application: the collector and the static dashboard it feeds.
COPY collector/collect.py ./collector/collect.py
COPY dashboard/index.html ./dashboard/index.html
COPY start.sh ./start.sh
# Tolerate CRLF line endings if the repo was checked out on Windows.
RUN sed -i 's/\r$//' ./start.sh && chmod +x ./start.sh
# Non-root user; it must be able to write metrics into the served directory.
RUN useradd --create-home --uid 10001 appuser \
&& mkdir -p /app/dashboard/data \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
# The dashboard page is the liveness signal for the whole container.
HEALTHCHECK --interval=60s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f http://localhost:8080/index.html || exit 1
CMD ["./start.sh"]

عرض الملف

@@ -0,0 +1,211 @@
# Q5 — Monitoring dashboard for mithal.space
A single container that continuously measures the availability and performance of
**https://mithal.space** and serves a live dashboard of the results on port 8080.
```
q5-mithal-dashboard/
├── collector/
│ ├── collect.py # measures latency, uptime, DNS, TLS, search — every 60s
│ └── requirements.txt # requests (pinned); everything else is stdlib
├── dashboard/
│ ├── index.html # self-contained dashboard (Chart.js from CDN)
│ └── data/metrics.json # rolling 48h JSON array, written by the collector
├── start.sh # entrypoint: collector in background + static server
├── Dockerfile
└── README.md
```
**Why `data/` lives inside `dashboard/`:** the dashboard directory *is* the web
root, so the page fetches `data/metrics.json` from its own origin. One container,
one port, no CORS, no API layer.
---
## 1. What is collected
Every 60 seconds `collector/collect.py` appends one record to the JSON array:
```json
{
"ts": "2026-07-26T15:46:40.381676+00:00",
"up": true,
"code": 200,
"latency_ms": 848.58,
"dns_ms": 0.67,
"ssl_days_left": 50,
"search_ms": 1056.08
}
```
| Field | How it is measured |
|---|---|
| `latency_ms` | timed `GET https://mithal.space`, 10 s timeout, redirects followed |
| `up` | `true` when the status code is **200399** |
| `code` | the HTTP status code — `null` when the connection itself failed |
| `dns_ms` | timed `socket.getaddrinfo("mithal.space", 443)` |
| `ssl_days_left` | TLS handshake to `mithal.space:443`, cert `notAfter` parsed → days remaining |
| `search_ms` | timed `GET https://mithal.space/search?q=test` (`null` if it errors or returns ≥ 400) |
Every measurement is independent: a failure records `null` for that field only and
never aborts the run or crashes the loop. Records older than **48 hours** are
pruned on each write, and the file is written atomically (temp file + rename) so
the dashboard never reads a half-written array.
**Configuration** — constants at the top of `collect.py`, all overridable by env var:
| Variable | Default | Meaning |
|---|---|---|
| `TARGET_URL` | `https://mithal.space` | site under test |
| `SEARCH_URL` | `https://mithal.space/search?q=test` | search endpoint to time |
| `INTERVAL_S` | `60` | seconds between collections |
| `TIMEOUT_S` | `10` | per-request timeout |
| `RETENTION_HOURS` | `48` | how much history to keep |
| `METRICS_FILE` | `dashboard/data/metrics.json` | output path |
---
## 2. Run locally (without Docker)
```bash
cd q5-mithal-dashboard
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r collector/requirements.txt
```
One-shot collection (useful as a smoke test or under cron):
```bash
python collector/collect.py --once
```
Continuous collection every 60 s — leave this running:
```bash
python collector/collect.py
```
In a second terminal, serve the dashboard (the `data/` directory must be inside
the served root, which it is):
```bash
cd q5-mithal-dashboard/dashboard && python -m http.server 8080
```
Open <http://localhost:8080/index.html>.
> Opening `index.html` straight from disk does **not** work — browsers block
> `fetch()` over `file://`. The page detects this and shows an explanatory banner
> instead of failing silently.
---
## 3. The dashboard
| Element | Detail |
|---|---|
| Status badge | green `UP` / red `DOWN` from the newest record, with HTTP code and timestamp |
| Uptime tile | `up_checks / total_checks × 100` over the **last 24 h**, one decimal |
| TLS card | "*X* days remaining" — green > 30, yellow 830, red ≤ 7 (`Expired` at ≤ 0) |
| Latest response | newest `latency_ms`, with `search_ms` and `dns_ms` underneath |
| Chart | `latency_ms` (solid blue) and `search_ms` (dashed purple) for the **last hour**; failed checks draw gaps, red points mark down checks |
| Table | last 10 checks — time, ✅/❌, code, latency, DNS, search |
| Refresh | re-fetches every 60 s; last-updated clock in the header |
Empty, missing, or corrupt data renders a "no data yet" state on every tile plus a
banner explaining what to do — it never throws.
To point the page elsewhere, edit the one constant at the top of the `<script>`:
```js
const DATA_URL = 'data/metrics.json';
```
---
## 4. Build the image
The container runs the collector in the background and serves the dashboard in the
foreground, both from one process tree (`start.sh`):
```bash
docker build -t mithal-monitor:latest ./q5-mithal-dashboard
```
```bash
docker run --rm -p 8080:8080 --name mithal-monitor mithal-monitor:latest
```
Open <http://localhost:8080/index.html>. The first record appears within a few
seconds of startup, then one per minute.
Image notes:
- `python:3.12-slim`, non-root user `appuser` (uid 10001) owning the served tree
so the collector can write into it.
- `requirements.txt` installed before the code is copied, for layer caching.
- `start.sh` restarts the collector if it ever exits, and traps `SIGTERM`/`SIGINT`
so the container stops promptly.
- `HEALTHCHECK` fetches the dashboard page itself.
- Metrics live inside the container's filesystem, so **history resets on redeploy**.
That is intentional for this exercise; to keep history across restarts, mount a
volume at `/app/dashboard/data` (this is exactly what Ghaymah Block Storage is
for — see Q4).
---
## 5. Deploy to Ghaymah
Ghaymah deploys from a **public image URL**, so push the image to Docker Hub first.
Replace `<MY_DOCKERHUB_USER>` with your account name.
```bash
docker login
```
```bash
docker tag mithal-monitor:latest docker.io/<MY_DOCKERHUB_USER>/mithal-monitor:latest
```
```bash
docker push docker.io/<MY_DOCKERHUB_USER>/mithal-monitor:latest
```
> On Apple Silicon / ARM, build for the platform Ghaymah runs:
> ```bash
> docker buildx build --platform linux/amd64 -t docker.io/<MY_DOCKERHUB_USER>/mithal-monitor:latest --push ./q5-mithal-dashboard
> ```
Make sure the Docker Hub repository is **public** — Ghaymah pulls it anonymously.
Then, in the Ghaymah dashboard:
| Field | Value |
|---|---|
| Container Image URL | `docker.io/<MY_DOCKERHUB_USER>/mithal-monitor:latest` |
| Application Name | `mithal-monitor` |
| Port Number | `8080` (matches `EXPOSE`) |
| Public Access | **enabled** |
| Environment Variables | *(optional)* `INTERVAL_S=60`, `TARGET_URL=https://mithal.space`, `SEARCH_URL=https://mithal.space/search?q=test` |
Click **Deploy**, then open `<the public URL Ghaymah assigns>/index.html` and
screenshot the live dashboard. Leave it running so the 24 h uptime tile and the
hourly chart fill with real history before submission.
---
## 6. Local verification performed
Measured against the live site on 2026-07-26:
| Check | Result |
|---|---|
| `collect.py --once` against mithal.space | `UP code=200 latency=2693ms dns=7.07ms search=866ms ssl=50d` |
| 60 s loop (run at 6 s for testing) | 5 further records appended, pruning and atomic writes working |
| Failure path (unreachable host) | recorded `up:false` with `code/latency_ms/dns_ms/ssl_days_left/search_ms` all `null`, exit 0, no crash |
| Dashboard against real `metrics.json` | badge UP, uptime 100.0% (6/6), TLS card "50 days — Healthy" in green, chart with both series, 6-row table; no console errors |
| Dashboard with the data file removed | "no data yet" state on every tile + banner, no crash |
| `docker build` | **not run** — the local Docker daemon was not running; build with the command in §4 before pushing |
The `dashboard/data/metrics.json` in this repo holds those first real samples; the
collector prunes anything older than 48 h automatically.

عرض الملف

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Metrics collector for https://mithal.space.
Every 60 seconds (or once with --once) it measures availability, page latency,
DNS resolution time, TLS certificate lifetime and search-endpoint latency, then
appends one record to the JSON array the dashboard reads.
Record schema:
{"ts": "<iso8601 utc>", "up": bool, "code": int|null, "latency_ms": float|null,
"dns_ms": float|null, "ssl_days_left": int|null, "search_ms": float|null}
A failing measurement never aborts the run — it is recorded as null.
Usage:
python collect.py # loop every 60s
python collect.py --once # single collection, then exit
"""
import argparse
import json
import os
import socket
import ssl
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlparse
import requests
# --- Configuration -----------------------------------------------------------
TARGET_URL = os.getenv("TARGET_URL", "https://mithal.space")
# Confirmed working search endpoint for mithal.space.
SEARCH_URL = os.getenv("SEARCH_URL", "https://mithal.space/search?q=test")
INTERVAL_S = float(os.getenv("INTERVAL_S", "60"))
TIMEOUT_S = float(os.getenv("TIMEOUT_S", "10"))
RETENTION_HOURS = float(os.getenv("RETENTION_HOURS", "48"))
# The dashboard is served as the web root and fetches "data/metrics.json",
# so the data directory lives inside dashboard/ both locally and in the image.
DEFAULT_METRICS_FILE = (
Path(__file__).resolve().parent.parent / "dashboard" / "data" / "metrics.json"
)
METRICS_FILE = Path(os.getenv("METRICS_FILE", str(DEFAULT_METRICS_FILE)))
USER_AGENT = "mithal-monitor/1.0 (+uptime collector)"
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def host_port(url: str) -> tuple:
"""Extract (hostname, port) from a URL, defaulting to 443 for https."""
parsed = urlparse(url)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return parsed.hostname, port
# --- Individual measurements (each returns None on failure) ------------------
def measure_page(url: str):
"""Timed GET of the home page. Returns (code|None, latency_ms|None)."""
started = time.perf_counter()
try:
resp = requests.get(
url, timeout=TIMEOUT_S, headers={"User-Agent": USER_AGENT}, allow_redirects=True
)
latency_ms = round((time.perf_counter() - started) * 1000, 2)
return resp.status_code, latency_ms
except requests.RequestException:
# Connection refused, DNS failure, TLS error, timeout — no status code.
return None, None
def measure_search(url: str):
"""Timed GET of the search endpoint. Returns search_ms|None."""
started = time.perf_counter()
try:
resp = requests.get(
url, timeout=TIMEOUT_S, headers={"User-Agent": USER_AGENT}, allow_redirects=True
)
if resp.status_code >= 400:
return None
return round((time.perf_counter() - started) * 1000, 2)
except requests.RequestException:
return None
def measure_dns(hostname: str, port: int):
"""Timed DNS resolution. Returns dns_ms|None."""
started = time.perf_counter()
try:
socket.getaddrinfo(hostname, port)
return round((time.perf_counter() - started) * 1000, 2)
except (socket.gaierror, OSError):
return None
def measure_ssl_days_left(hostname: str, port: int):
"""Days until the TLS certificate expires. Returns int|None."""
context = ssl.create_default_context()
try:
with socket.create_connection((hostname, port), timeout=TIMEOUT_S) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as tls:
cert = tls.getpeercert()
except (ssl.SSLError, socket.error, OSError):
return None
not_after = (cert or {}).get("notAfter")
if not not_after:
return None
try:
# OpenSSL format, always in GMT: "Sep 12 23:59:59 2026 GMT"
expires = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z").replace(
tzinfo=timezone.utc
)
except ValueError:
return None
return (expires - now_utc()).days
def collect_once() -> dict:
"""Run every measurement and build one record. Never raises."""
hostname, port = host_port(TARGET_URL)
dns_ms = measure_dns(hostname, port) if hostname else None
code, latency_ms = measure_page(TARGET_URL)
ssl_days_left = measure_ssl_days_left(hostname, port) if hostname else None
search_ms = measure_search(SEARCH_URL)
return {
"ts": now_utc().isoformat(),
"up": code is not None and 200 <= code < 400,
"code": code,
"latency_ms": latency_ms,
"dns_ms": dns_ms,
"ssl_days_left": ssl_days_left,
"search_ms": search_ms,
}
# --- Storage -----------------------------------------------------------------
def load_records(path: Path) -> list:
"""Read the existing JSON array; tolerate a missing or corrupt file."""
if not path.exists():
return []
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, list) else []
except (json.JSONDecodeError, OSError):
print(f"[warn] {path} unreadable or corrupt — starting a fresh array", file=sys.stderr)
return []
def prune(records: list) -> list:
"""Drop records older than the retention window."""
cutoff = now_utc() - timedelta(hours=RETENTION_HOURS)
kept = []
for rec in records:
if not isinstance(rec, dict):
continue
try:
ts = datetime.fromisoformat(str(rec.get("ts")))
except (TypeError, ValueError):
continue
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
if ts >= cutoff:
kept.append(rec)
return kept
def append_record(path: Path, record: dict) -> None:
"""Append a record, prune the window, and write atomically."""
path.parent.mkdir(parents=True, exist_ok=True)
records = prune(load_records(path))
records.append(record)
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as fh:
json.dump(records, fh, indent=2)
fh.write("\n")
tmp.replace(path)
def format_line(rec: dict) -> str:
def num(value, suffix=""):
return f"{value}{suffix}" if value is not None else "-"
return (
f"{rec['ts']} {'UP ' if rec['up'] else 'DOWN'} "
f"code={num(rec['code']):<4} "
f"latency={num(rec['latency_ms'], 'ms'):<10} "
f"dns={num(rec['dns_ms'], 'ms'):<9} "
f"search={num(rec['search_ms'], 'ms'):<10} "
f"ssl={num(rec['ssl_days_left'], 'd')}"
)
def main() -> int:
parser = argparse.ArgumentParser(description="Collect availability metrics for mithal.space.")
parser.add_argument("--once", action="store_true", help="collect a single sample and exit")
args = parser.parse_args()
print(f"Target: {TARGET_URL}", flush=True)
print(f"Search: {SEARCH_URL}", flush=True)
print(f"Output: {METRICS_FILE} (keeping {RETENTION_HOURS:g}h)", flush=True)
if not args.once:
print(f"Interval: {INTERVAL_S:g}s", flush=True)
while True:
try:
record = collect_once()
append_record(METRICS_FILE, record)
print(format_line(record), flush=True)
except Exception as exc: # a bug here must not kill a long-running collector
print(f"[error] collection failed: {exc!r}", file=sys.stderr, flush=True)
if args.once:
return 0
time.sleep(INTERVAL_S)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nStopped.", flush=True)

عرض الملف

@@ -0,0 +1 @@
requests==2.32.3

عرض الملف

@@ -0,0 +1,56 @@
[
{
"ts": "2026-07-26T15:44:13.109453+00:00",
"up": true,
"code": 200,
"latency_ms": 2693.14,
"dns_ms": 7.07,
"ssl_days_left": 50,
"search_ms": 866.47
},
{
"ts": "2026-07-26T15:46:03.954555+00:00",
"up": true,
"code": 200,
"latency_ms": 1057.54,
"dns_ms": 7.42,
"ssl_days_left": 50,
"search_ms": 958.25
},
{
"ts": "2026-07-26T15:46:12.414865+00:00",
"up": true,
"code": 200,
"latency_ms": 1016.96,
"dns_ms": 0.56,
"ssl_days_left": 50,
"search_ms": 942.73
},
{
"ts": "2026-07-26T15:46:20.678443+00:00",
"up": true,
"code": 200,
"latency_ms": 781.13,
"dns_ms": 0.38,
"ssl_days_left": 50,
"search_ms": 956.94
},
{
"ts": "2026-07-26T15:46:32.017215+00:00",
"up": true,
"code": 200,
"latency_ms": 1828.99,
"dns_ms": 0.5,
"ssl_days_left": 50,
"search_ms": 2967.25
},
{
"ts": "2026-07-26T15:46:40.381676+00:00",
"up": true,
"code": 200,
"latency_ms": 848.58,
"dns_ms": 0.67,
"ssl_days_left": 50,
"search_ms": 1056.08
}
]

عرض الملف

@@ -0,0 +1,473 @@
<!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>

عرض الملف

@@ -0,0 +1,26 @@
#!/bin/sh
# Entrypoint: run the collector in the background, serve the dashboard in the
# foreground. The collector writes into the served directory, so the page can
# fetch data/metrics.json from the same origin — no CORS, no second service.
set -eu
WEB_ROOT="${WEB_ROOT:-/app/dashboard}"
PORT="${PORT:-8080}"
mkdir -p "$WEB_ROOT/data"
# Keep the collector alive: if it ever exits, restart it after a short pause
# rather than leaving the dashboard serving frozen data.
(
while true; do
python /app/collector/collect.py || echo "[start.sh] collector exited, restarting in 10s" >&2
sleep 10
done
) &
COLLECTOR_PID=$!
# Stop both processes on SIGTERM/SIGINT so the container shuts down promptly.
trap 'kill "$COLLECTOR_PID" 2>/dev/null || true; exit 0' TERM INT
echo "[start.sh] serving $WEB_ROOT on port $PORT"
exec python -m http.server "$PORT" --directory "$WEB_ROOT" --bind 0.0.0.0