Complete Ghaymah cloud assessment
هذا الالتزام موجود في:
41
q5-mithal-dashboard/Dockerfile
Normal file
41
q5-mithal-dashboard/Dockerfile
Normal file
@@ -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"]
|
||||
211
q5-mithal-dashboard/README.md
Normal file
211
q5-mithal-dashboard/README.md
Normal file
@@ -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 **200–399** |
|
||||
| `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 8–30, 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.
|
||||
237
q5-mithal-dashboard/collector/collect.py
Normal file
237
q5-mithal-dashboard/collector/collect.py
Normal file
@@ -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)
|
||||
1
q5-mithal-dashboard/collector/requirements.txt
Normal file
1
q5-mithal-dashboard/collector/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
requests==2.32.3
|
||||
56
q5-mithal-dashboard/dashboard/data/metrics.json
Normal file
56
q5-mithal-dashboard/dashboard/data/metrics.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
473
q5-mithal-dashboard/dashboard/index.html
Normal file
473
q5-mithal-dashboard/dashboard/index.html
Normal file
@@ -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>
|
||||
26
q5-mithal-dashboard/start.sh
Normal file
26
q5-mithal-dashboard/start.sh
Normal file
@@ -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
|
||||
المرجع في مشكلة جديدة
حظر مستخدم