Complete Ghaymah cloud assessment
هذا الالتزام موجود في:
262
q1-deploy-monitor/README.md
Normal file
262
q1-deploy-monitor/README.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Q1 — Deploy & Monitor an API on Ghaymah
|
||||
|
||||
A minimal FastAPI service, containerised and deployed on the **Ghaymah** container
|
||||
platform, plus a polling monitor and a static dashboard that visualises its
|
||||
availability, response time and request count.
|
||||
|
||||
```
|
||||
q1-deploy-monitor/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI app: /, /health, /metrics
|
||||
│ ├── requirements.txt # pinned fastapi + uvicorn
|
||||
│ ├── Dockerfile # python:3.12-slim, non-root, HEALTHCHECK
|
||||
│ └── .dockerignore
|
||||
├── monitor/
|
||||
│ ├── monitor.py # polls /health + /metrics every 30s (stdlib only)
|
||||
│ └── data/checks.json # created on first run — the dashboard's data source
|
||||
├── dashboard/
|
||||
│ └── index.html # single self-contained page (Chart.js from CDN)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. The API
|
||||
|
||||
| Method | Path | Response |
|
||||
|---|---|---|
|
||||
| `GET` | `/` | service name, version, endpoint list, start time |
|
||||
| `GET` | `/health` | `{"status":"ok","uptime_s":12.34,"timestamp":"2026-07-26T15:36:50.283265+00:00"}` — HTTP 200 |
|
||||
| `GET` | `/metrics` | `{"requests_total":42,"started_at":"<iso8601>"}` |
|
||||
| `GET` | `/docs` | interactive OpenAPI docs (FastAPI built-in) |
|
||||
|
||||
`/health` performs **no** downstream checks (no DB, no network) — it reports
|
||||
process liveness only, so a failing check always means "restart me", which is
|
||||
exactly the signal an orchestrator's health probe should act on.
|
||||
|
||||
`/metrics` is backed by an in-memory counter incremented by an HTTP middleware on
|
||||
every request. It is per-process and deliberately resets on restart — a counter
|
||||
that drops to zero in the dashboard is a visible signal that the container was
|
||||
restarted or redeployed.
|
||||
|
||||
Environment variables (all optional): `APP_NAME`, `APP_VERSION`, `PORT` (default `8080`).
|
||||
|
||||
### Run locally without Docker
|
||||
|
||||
```bash
|
||||
cd q1-deploy-monitor/app
|
||||
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
python main.py # or: uvicorn main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Build and run the Docker image locally
|
||||
|
||||
```bash
|
||||
docker build -t ghaymah-api:latest ./q1-deploy-monitor/app
|
||||
```
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8080:8080 --name ghaymah-api ghaymah-api:latest
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
curl -f http://localhost:8080/health && curl http://localhost:8080/metrics
|
||||
```
|
||||
|
||||
Docker's own health probe (defined by `HEALTHCHECK` in the Dockerfile) shows up
|
||||
after ~10s in `docker ps` as `(healthy)`:
|
||||
|
||||
```bash
|
||||
docker ps --filter name=ghaymah-api
|
||||
```
|
||||
|
||||
**Image design notes** (what the Dockerfile is doing and why):
|
||||
|
||||
- `python:3.12-slim` — small base, no build toolchain in the final image.
|
||||
- `requirements.txt` is copied and installed **before** the application code, so
|
||||
editing `main.py` reuses the cached dependency layer instead of reinstalling
|
||||
FastAPI on every build.
|
||||
- Runs as the non-root user `appuser` (uid 10001).
|
||||
- `curl` is the only extra apt package, installed solely for the `HEALTHCHECK`;
|
||||
apt lists are removed in the same layer.
|
||||
- `EXPOSE 8080` matches the port Ghaymah is configured with below.
|
||||
|
||||
---
|
||||
|
||||
## 3. Push the image and deploy on Ghaymah
|
||||
|
||||
Ghaymah deploys a container from a **public image URL**, so the image must live in
|
||||
a public registry first. Docker Hub is used here — replace `<MY_DOCKERHUB_USER>`
|
||||
with your own account name.
|
||||
|
||||
### 3.1 Push to Docker Hub
|
||||
|
||||
```bash
|
||||
docker login
|
||||
```
|
||||
|
||||
```bash
|
||||
docker tag ghaymah-api:latest docker.io/<MY_DOCKERHUB_USER>/ghaymah-api:latest
|
||||
```
|
||||
|
||||
```bash
|
||||
docker push docker.io/<MY_DOCKERHUB_USER>/ghaymah-api:latest
|
||||
```
|
||||
|
||||
> Building on an Apple Silicon / ARM machine? Build for the platform Ghaymah runs
|
||||
> (`linux/amd64`) or the container will fail to start:
|
||||
> ```bash
|
||||
> docker buildx build --platform linux/amd64 -t docker.io/<MY_DOCKERHUB_USER>/ghaymah-api:latest --push ./q1-deploy-monitor/app
|
||||
> ```
|
||||
|
||||
Make sure the Docker Hub repository is **public** — Ghaymah pulls the image
|
||||
anonymously from the URL you paste in.
|
||||
|
||||
### 3.2 Deploy on the Ghaymah dashboard
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Container Image URL | `docker.io/<MY_DOCKERHUB_USER>/ghaymah-api:latest` |
|
||||
| Application Name | `ghaymah-api` |
|
||||
| Port Number | `8080` (must match the `EXPOSE`d port) |
|
||||
| Public Access | **enabled** |
|
||||
| Environment Variables | *(optional)* `APP_NAME=ghaymah-api`, `APP_VERSION=1.0.0` |
|
||||
|
||||
Then click **Deploy**. Once the deployment reports as running, Ghaymah assigns the
|
||||
service a public URL.
|
||||
|
||||
### 3.3 Verify the live deployment
|
||||
|
||||
```bash
|
||||
curl -f <the public URL Ghaymah assigns>/health
|
||||
```
|
||||
|
||||
Expected: HTTP 200 with `{"status":"ok","uptime_s":...,"timestamp":"..."}`.
|
||||
|
||||
Also open `<the public URL Ghaymah assigns>/docs` in a browser for the OpenAPI page,
|
||||
and screenshot both the running service in the Ghaymah dashboard and the `/health`
|
||||
response for the submission.
|
||||
|
||||
> **Redeploying a new version:** push a new image tag and update the Container
|
||||
> Image URL on the service. Prefer an explicit tag (e.g. `:v2` or the git SHA)
|
||||
> over `:latest` so a redeploy is unambiguous about which build is running.
|
||||
|
||||
---
|
||||
|
||||
## 4. Run the monitor
|
||||
|
||||
`monitor/monitor.py` uses the **Python standard library only** — nothing to install.
|
||||
|
||||
```bash
|
||||
export APP_URL="<the public URL Ghaymah assigns>"
|
||||
python q1-deploy-monitor/monitor/monitor.py
|
||||
```
|
||||
|
||||
On Windows PowerShell:
|
||||
|
||||
```bash
|
||||
$env:APP_URL="<the public URL Ghaymah assigns>"; python q1-deploy-monitor\monitor\monitor.py
|
||||
```
|
||||
|
||||
Every 30 seconds it issues `GET $APP_URL/health` with a 5 s timeout, then
|
||||
`GET $APP_URL/metrics`, and appends one record to `monitor/data/checks.json`
|
||||
(a JSON array, created on first run):
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-07-26T15:37:29.886772+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 65.3,
|
||||
"requests": 2
|
||||
}
|
||||
```
|
||||
|
||||
- Any timeout, connection error, TLS failure or non-2xx response → `"status":"down"`
|
||||
with `latency_ms: null` (and the HTTP code when the server did answer).
|
||||
- `/metrics` is best-effort: if only that call fails, the check still counts as
|
||||
**up** and `requests` is `null`.
|
||||
- After **3 consecutive failures** it prints an `ALERT:` line to stdout (once per
|
||||
outage), and a `RECOVERED:` line when the service answers again.
|
||||
|
||||
Single check (useful for cron, CI or a smoke test — exits `0` if up, `1` if down):
|
||||
|
||||
```bash
|
||||
APP_URL="<the public URL Ghaymah assigns>" python q1-deploy-monitor/monitor/monitor.py --once
|
||||
```
|
||||
|
||||
Leave the loop running well before the submission so the dashboard has real history.
|
||||
|
||||
**Tuning via environment variables**
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `APP_URL` | *(required)* | base URL of the deployed app (also settable with `--url`) |
|
||||
| `INTERVAL_S` | `30` | seconds between checks |
|
||||
| `TIMEOUT_S` | `5` | per-request timeout |
|
||||
| `ALERT_AFTER` | `3` | consecutive failures before the ALERT line |
|
||||
| `DATA_FILE` | `monitor/data/checks.json` | where records are written |
|
||||
| `MAX_RECORDS` | `2880` | rolling window (24 h at one check / 30 s) |
|
||||
|
||||
Records are written atomically (temp file + rename), so the dashboard never reads
|
||||
a half-written file.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open the dashboard
|
||||
|
||||
The page fetches `../monitor/data/checks.json`, so it must be served over HTTP —
|
||||
opening `index.html` directly from the filesystem is blocked by the browser's
|
||||
`file://` fetch restrictions (the page detects this and tells you so instead of
|
||||
failing silently).
|
||||
|
||||
```bash
|
||||
cd q1-deploy-monitor && python -m http.server 8000
|
||||
```
|
||||
|
||||
Then open <http://localhost:8000/dashboard/index.html>.
|
||||
|
||||
It shows:
|
||||
|
||||
- **Status badge** — green `UP` / red `DOWN` from the most recent check, with the
|
||||
HTTP code and timestamp.
|
||||
- **Total requests** — `requests_total` from the latest check.
|
||||
- **Latest latency** + the average across the stored window.
|
||||
- **Uptime %** across all stored checks.
|
||||
- **Latency line chart** — `latency_ms` over time (last 120 checks); down checks
|
||||
appear as gaps with red points.
|
||||
- **Last-updated timestamp**, auto-refreshing every 30 s.
|
||||
|
||||
With no data (monitor not started yet, file missing, or an empty/corrupt array)
|
||||
it renders a "no data yet" state and an explanatory banner rather than erroring.
|
||||
|
||||
To point the page at a different data file, edit the one constant at the top of
|
||||
the `<script>` block:
|
||||
|
||||
```js
|
||||
const DATA_URL = '../monitor/data/checks.json';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Local verification performed
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `pip install -r requirements.txt` (pinned versions) | fastapi 0.115.6, uvicorn 0.34.0 installed cleanly |
|
||||
| `GET /health` | HTTP 200 · `{"status":"ok","uptime_s":7.797,"timestamp":"..."}` |
|
||||
| `GET /` and `GET /metrics` | valid JSON; `requests_total` increments per request |
|
||||
| `monitor.py --once` against the running app | `UP code=200 latency=45.8ms requests=5`, exit 0 |
|
||||
| `monitor.py` loop across an app shutdown | up records → down records → `ALERT` printed on the 3rd consecutive failure |
|
||||
| Dashboard against real `checks.json` | badge, tiles, chart and uptime % all rendered; no console errors |
|
||||
| Dashboard with the data file removed | "no data yet" state + banner, no crash |
|
||||
| `docker build` | **not run** — the local Docker daemon was not running; build it with the command in §2 before pushing |
|
||||
29
q1-deploy-monitor/app/.dockerignore
Normal file
29
q1-deploy-monitor/app/.dockerignore
Normal file
@@ -0,0 +1,29 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Tooling / editors
|
||||
.git/
|
||||
.gitignore
|
||||
.github/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project files that don't belong in the image
|
||||
*.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.env
|
||||
*.log
|
||||
33
q1-deploy-monitor/app/Dockerfile
Normal file
33
q1-deploy-monitor/app/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# curl is required by the HEALTHCHECK below; everything else stays out of the image.
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dependencies first: this layer is cached and only rebuilt when
|
||||
# requirements.txt changes, not on every code edit.
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Application code last — the layer that changes most often.
|
||||
COPY main.py .
|
||||
|
||||
# Run as an unprivileged user.
|
||||
RUN useradd --create-home --uid 10001 appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
88
q1-deploy-monitor/app/main.py
Normal file
88
q1-deploy-monitor/app/main.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Ghaymah demo API — liveness and request metrics with no external dependencies.
|
||||
|
||||
Endpoints:
|
||||
GET / basic service info
|
||||
GET /health liveness probe (process-only, no downstream checks)
|
||||
GET /metrics in-memory request counter
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
APP_NAME = os.getenv("APP_NAME", "ghaymah-api")
|
||||
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
||||
PORT = int(os.getenv("PORT", "8080"))
|
||||
|
||||
# Process start time drives both uptime and the /metrics started_at field.
|
||||
STARTED_AT_MONOTONIC = time.monotonic()
|
||||
STARTED_AT_ISO = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
app = FastAPI(
|
||||
title=APP_NAME,
|
||||
version=APP_VERSION,
|
||||
description="Minimal API deployed on the Ghaymah container platform.",
|
||||
)
|
||||
|
||||
# In-memory counter. Deliberately per-process: it resets on restart, which is
|
||||
# exactly what we want the dashboard to show after a redeploy or a crash-loop.
|
||||
_requests_total = 0
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _uptime_s() -> float:
|
||||
return round(time.monotonic() - STARTED_AT_MONOTONIC, 3)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def count_requests(request: Request, call_next):
|
||||
"""Increment the counter for every request, including failed ones."""
|
||||
global _requests_total
|
||||
_requests_total += 1
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"service": APP_NAME,
|
||||
"version": APP_VERSION,
|
||||
"message": "Ghaymah deployment demo API",
|
||||
"endpoints": ["/", "/health", "/metrics", "/docs"],
|
||||
"started_at": STARTED_AT_ISO,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Liveness only — no DB or network calls, so it never fails for a
|
||||
downstream reason the orchestrator cannot fix by restarting us."""
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"status": "ok",
|
||||
"uptime_s": _uptime_s(),
|
||||
"timestamp": _now_iso(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/metrics")
|
||||
async def metrics():
|
||||
return {
|
||||
"requests_total": _requests_total,
|
||||
"started_at": STARTED_AT_ISO,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=PORT, log_level="info")
|
||||
2
q1-deploy-monitor/app/requirements.txt
Normal file
2
q1-deploy-monitor/app/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
398
q1-deploy-monitor/dashboard/index.html
Normal file
398
q1-deploy-monitor/dashboard/index.html
Normal file
@@ -0,0 +1,398 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Ghaymah API — 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;
|
||||
--panel-2: #1c2129;
|
||||
--border: #262d38;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--up: #3fb950;
|
||||
--up-dim: rgba(63, 185, 80, .14);
|
||||
--down: #f85149;
|
||||
--down-dim: rgba(248, 81, 73, .14);
|
||||
--accent: #58a6ff;
|
||||
--warn: #d29922;
|
||||
}
|
||||
|
||||
* { 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: 1080px; margin: 0 auto; }
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
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(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
.card .label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.card .value {
|
||||
font-size: 30px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.card .sub { margin-top: 6px; font-size: 13px; color: var(--muted); }
|
||||
|
||||
/* Status card */
|
||||
.status-card { display: flex; flex-direction: column; justify-content: center; }
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.badge .dot {
|
||||
width: 11px; height: 11px; border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 0 0 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: 18px; }
|
||||
.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); }
|
||||
}
|
||||
|
||||
.chart-card { padding: 20px 20px 12px; }
|
||||
.chart-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.chart-head .label { margin: 0; }
|
||||
.chart-box { position: relative; height: 300px; }
|
||||
|
||||
.empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; min-height: 120px;
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
code.path { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<h1>Ghaymah API <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 status-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 check…</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="label">Total requests</div>
|
||||
<div class="value" id="requests">—</div>
|
||||
<div class="sub" id="requestsSub">from /metrics</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="label">Latest latency</div>
|
||||
<div class="value" id="latency">—</div>
|
||||
<div class="sub" id="latencySub">avg — · checks —</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="label">Uptime (all checks)</div>
|
||||
<div class="value" id="uptime">—</div>
|
||||
<div class="sub" id="uptimeSub">— up / — down</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card chart-card">
|
||||
<div class="chart-head">
|
||||
<div class="label">Response time — latency_ms over time</div>
|
||||
<div class="updated" id="chartRange"></div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<canvas id="chart"></canvas>
|
||||
<div id="chartEmpty" class="empty" style="display:none">No data yet — start the monitor to collect checks.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<span>Data source: <code class="path" id="srcPath"></code></span>
|
||||
<span>Auto-refresh every 30s</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path to the JSON array written by monitor/monitor.py. Change if you serve
|
||||
// the dashboard from a different location.
|
||||
const DATA_URL = '../monitor/data/checks.json';
|
||||
const REFRESH_MS = 30000;
|
||||
const MAX_POINTS = 120; // most recent N checks shown on the chart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
$('srcPath').textContent = DATA_URL;
|
||||
|
||||
let chart = null;
|
||||
|
||||
function fmtTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return String(iso ?? '—');
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtDateTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return String(iso ?? '—');
|
||||
return d.toLocaleString([], { month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function showBanner(msg) {
|
||||
const b = $('banner');
|
||||
b.innerHTML = msg;
|
||||
b.classList.add('show');
|
||||
}
|
||||
function hideBanner() { $('banner').classList.remove('show'); }
|
||||
|
||||
function setBadge(state, text) {
|
||||
const badge = $('badge');
|
||||
badge.className = 'badge ' + state;
|
||||
$('badgeText').textContent = text;
|
||||
}
|
||||
|
||||
function renderEmpty(reason) {
|
||||
setBadge('unknown', 'NO DATA');
|
||||
$('statusSub').textContent = reason || 'Waiting for the first check…';
|
||||
$('requests').textContent = '—';
|
||||
$('latency').textContent = '—';
|
||||
$('latencySub').textContent = 'avg — · checks —';
|
||||
$('uptime').textContent = '—';
|
||||
$('uptimeSub').textContent = '— up / — down';
|
||||
$('chartRange').textContent = '';
|
||||
$('chartEmpty').style.display = 'flex';
|
||||
$('chart').style.display = 'none';
|
||||
if (chart) { chart.destroy(); chart = null; }
|
||||
}
|
||||
|
||||
function renderChart(points) {
|
||||
$('chartEmpty').style.display = 'none';
|
||||
$('chart').style.display = 'block';
|
||||
|
||||
const labels = points.map(p => fmtTime(p.ts));
|
||||
// Down checks have no latency — null creates a visible gap in the line.
|
||||
const data = points.map(p => (typeof p.latency_ms === 'number' ? p.latency_ms : null));
|
||||
const colors = points.map(p => (p.status === 'up' ? '#3fb950' : '#f85149'));
|
||||
|
||||
if (chart) {
|
||||
chart.data.labels = labels;
|
||||
chart.data.datasets[0].data = data;
|
||||
chart.data.datasets[0].pointBackgroundColor = colors;
|
||||
chart.update('none');
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = $('chart').getContext('2d');
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 300);
|
||||
gradient.addColorStop(0, 'rgba(88,166,255,.28)');
|
||||
gradient.addColorStop(1, 'rgba(88,166,255,0)');
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'latency (ms)',
|
||||
data,
|
||||
borderColor: '#58a6ff',
|
||||
backgroundColor: gradient,
|
||||
borderWidth: 2,
|
||||
pointRadius: 2.5,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: colors,
|
||||
pointBorderWidth: 0,
|
||||
tension: .3,
|
||||
fill: true,
|
||||
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,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: (c) => c.parsed.y === null ? 'down — no response' : `${c.parsed.y.toFixed(1)} 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 render(records) {
|
||||
if (!Array.isArray(records) || records.length === 0) {
|
||||
renderEmpty('No checks recorded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = records.filter(r => r && typeof r === 'object');
|
||||
if (valid.length === 0) { renderEmpty('Data file contains no usable checks.'); return; }
|
||||
|
||||
const latest = valid[valid.length - 1];
|
||||
const isUp = latest.status === 'up';
|
||||
|
||||
setBadge(isUp ? 'up' : 'down', isUp ? 'UP' : 'DOWN');
|
||||
$('statusSub').textContent =
|
||||
(latest.code != null ? `HTTP ${latest.code}` : 'no response') + ' · ' + fmtDateTime(latest.ts);
|
||||
|
||||
$('requests').textContent =
|
||||
typeof latest.requests === 'number' ? latest.requests.toLocaleString() : '—';
|
||||
$('requestsSub').textContent =
|
||||
typeof latest.requests === 'number' ? 'from /metrics' : '/metrics unavailable';
|
||||
|
||||
$('latency').textContent =
|
||||
typeof latest.latency_ms === 'number' ? `${latest.latency_ms.toFixed(0)} ms` : '—';
|
||||
|
||||
const latencies = valid.map(r => r.latency_ms).filter(v => typeof v === 'number');
|
||||
const avg = latencies.length
|
||||
? (latencies.reduce((a, b) => a + b, 0) / latencies.length).toFixed(0) + ' ms'
|
||||
: '—';
|
||||
$('latencySub').textContent = `avg ${avg} · ${valid.length} checks`;
|
||||
|
||||
const upCount = valid.filter(r => r.status === 'up').length;
|
||||
const pct = (upCount / valid.length) * 100;
|
||||
$('uptime').textContent = `${pct.toFixed(1)}%`;
|
||||
$('uptimeSub').textContent = `${upCount} up / ${valid.length - upCount} down`;
|
||||
|
||||
const points = valid.slice(-MAX_POINTS);
|
||||
$('chartRange').textContent =
|
||||
points.length > 1 ? `${fmtTime(points[0].ts)} → ${fmtTime(points[points.length - 1].ts)}` : '';
|
||||
renderChart(points);
|
||||
}
|
||||
|
||||
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 file:// CORS block — never crash the page.
|
||||
renderEmpty('Could not load monitoring data.');
|
||||
showBanner(
|
||||
`Could not read <code>${DATA_URL}</code> (${err.message}). ` +
|
||||
`Run the monitor to create it, and serve this page over HTTP ` +
|
||||
`(<code>python -m http.server 8000</code> from <code>q1-deploy-monitor/</code>) ` +
|
||||
`— opening the file directly with <code>file://</code> blocks the fetch.`
|
||||
);
|
||||
}
|
||||
$('updated').textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
load();
|
||||
setInterval(load, REFRESH_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
625
q1-deploy-monitor/monitor/data/checks.json
Normal file
625
q1-deploy-monitor/monitor/data/checks.json
Normal file
@@ -0,0 +1,625 @@
|
||||
[
|
||||
{
|
||||
"ts": "2026-07-26T16:00:54.022563+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 48.44,
|
||||
"requests": 5
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:01:24.088718+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 12.02,
|
||||
"requests": 8
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:01:54.237407+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 24.42,
|
||||
"requests": 11
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:02:24.326513+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 10.23,
|
||||
"requests": 14
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:02:54.359082+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.32,
|
||||
"requests": 19
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:03:24.389873+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 7.83,
|
||||
"requests": 22
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:03:54.430952+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 11.57,
|
||||
"requests": 25
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:04:24.463587+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.01,
|
||||
"requests": 28
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:04:54.502315+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 11.91,
|
||||
"requests": 31
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:05:24.545377+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 7.72,
|
||||
"requests": 34
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:05:54.596001+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.86,
|
||||
"requests": 37
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:06:24.623029+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.53,
|
||||
"requests": 40
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:06:54.659187+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.9,
|
||||
"requests": 43
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:07:24.697242+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 11.24,
|
||||
"requests": 46
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:07:54.732633+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.15,
|
||||
"requests": 49
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:08:24.764311+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 7.73,
|
||||
"requests": 52
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:08:54.794053+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 7.53,
|
||||
"requests": 55
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:09:24.830576+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 10.88,
|
||||
"requests": 58
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:09:55.392240+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 9.02,
|
||||
"requests": 61
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:10:25.432620+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 7.75,
|
||||
"requests": 64
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:10:55.469947+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 9.34,
|
||||
"requests": 67
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:11:25.502913+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 7.92,
|
||||
"requests": 70
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:11:55.542608+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 11.26,
|
||||
"requests": 73
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:12:25.579631+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 10.91,
|
||||
"requests": 76
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:12:55.621565+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.02,
|
||||
"requests": 79
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:13:25.651738+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 7.58,
|
||||
"requests": 82
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:13:55.683853+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 9.19,
|
||||
"requests": 85
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:14:25.720913+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 10.67,
|
||||
"requests": 88
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:14:55.754438+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.68,
|
||||
"requests": 91
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:15:25.795149+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 9.94,
|
||||
"requests": 94
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:15:55.835606+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.67,
|
||||
"requests": 97
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:16:25.894224+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 21.72,
|
||||
"requests": 100
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:16:55.947740+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.81,
|
||||
"requests": 103
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:17:25.993670+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 14.76,
|
||||
"requests": 106
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:17:56.027955+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 9.95,
|
||||
"requests": 109
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:18:26.076150+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 16.53,
|
||||
"requests": 112
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:18:56.130454+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 13.91,
|
||||
"requests": 115
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:19:26.181118+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 14.55,
|
||||
"requests": 118
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:19:56.239466+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 15.54,
|
||||
"requests": 121
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:20:26.273327+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 12.98,
|
||||
"requests": 124
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:20:56.326056+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 10.54,
|
||||
"requests": 127
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:21:26.360754+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.28,
|
||||
"requests": 130
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:21:56.419699+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 24.33,
|
||||
"requests": 133
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:22:26.474809+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 16.0,
|
||||
"requests": 136
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:22:56.537278+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 25.59,
|
||||
"requests": 139
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:23:26.588750+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 11.74,
|
||||
"requests": 142
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:23:56.625763+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 11.76,
|
||||
"requests": 145
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:24:26.678666+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 21.03,
|
||||
"requests": 148
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:24:56.718058+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 9.81,
|
||||
"requests": 151
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:25:26.781135+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 26.99,
|
||||
"requests": 154
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:25:56.837517+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 17.22,
|
||||
"requests": 157
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:26:26.885310+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 15.99,
|
||||
"requests": 160
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:26:56.931050+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 12.71,
|
||||
"requests": 163
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:27:26.978657+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 15.71,
|
||||
"requests": 166
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:27:57.027222+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 15.87,
|
||||
"requests": 169
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:28:27.076324+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 11.98,
|
||||
"requests": 172
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:28:57.144076+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 24.72,
|
||||
"requests": 175
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:29:27.203425+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 20.56,
|
||||
"requests": 178
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:29:57.243755+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 7.91,
|
||||
"requests": 181
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:30:27.305659+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 19.38,
|
||||
"requests": 184
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:30:57.380343+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 37.99,
|
||||
"requests": 187
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:31:27.412862+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.02,
|
||||
"requests": 190
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:31:57.458486+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 16.76,
|
||||
"requests": 193
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:32:27.506190+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 10.54,
|
||||
"requests": 196
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:32:57.558316+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 19.22,
|
||||
"requests": 199
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:33:27.636370+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 18.47,
|
||||
"requests": 202
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:33:57.692190+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 20.24,
|
||||
"requests": 205
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:34:27.737228+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 10.93,
|
||||
"requests": 208
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:34:57.800304+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 22.88,
|
||||
"requests": 211
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:35:27.861991+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 18.19,
|
||||
"requests": 214
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:35:57.925683+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 19.93,
|
||||
"requests": 217
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:36:27.992496+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 18.07,
|
||||
"requests": 220
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:36:58.067518+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 18.62,
|
||||
"requests": 223
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:37:28.127809+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 20.59,
|
||||
"requests": 226
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:37:58.166210+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 9.57,
|
||||
"requests": 229
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:38:28.201786+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 7.95,
|
||||
"requests": 232
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:38:58.237767+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.12,
|
||||
"requests": 235
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:39:28.279617+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 11.27,
|
||||
"requests": 238
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:39:58.317037+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 7.61,
|
||||
"requests": 241
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:40:28.355043+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 9.57,
|
||||
"requests": 244
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:40:58.391288+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 9.27,
|
||||
"requests": 247
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:41:28.432715+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 13.3,
|
||||
"requests": 250
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:41:58.503792+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 26.98,
|
||||
"requests": 253
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:42:28.608440+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 19.86,
|
||||
"requests": 256
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:42:58.693278+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 12.8,
|
||||
"requests": 259
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:43:28.797562+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 42.28,
|
||||
"requests": 262
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:43:58.874830+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 8.97,
|
||||
"requests": 265
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:44:28.919903+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 13.56,
|
||||
"requests": 268
|
||||
},
|
||||
{
|
||||
"ts": "2026-07-26T16:44:59.037760+00:00",
|
||||
"status": "up",
|
||||
"code": 200,
|
||||
"latency_ms": 34.65,
|
||||
"requests": 271
|
||||
}
|
||||
]
|
||||
204
q1-deploy-monitor/monitor/monitor.py
Normal file
204
q1-deploy-monitor/monitor/monitor.py
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Uptime monitor for the API deployed on Ghaymah.
|
||||
|
||||
Polls ``$APP_URL/health`` (and ``/metrics``) every 30 seconds and appends one
|
||||
record per check to ``monitor/data/checks.json`` — the file the dashboard reads.
|
||||
|
||||
Record schema:
|
||||
{"ts": "<iso8601>", "status": "up"|"down", "code": <int|null>,
|
||||
"latency_ms": <float|null>, "requests": <int|null>}
|
||||
|
||||
Usage:
|
||||
APP_URL=https://my-app.example python monitor.py
|
||||
APP_URL=https://my-app.example python monitor.py --once
|
||||
|
||||
Standard library only — no pip install needed to run the monitor.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# --- Configuration (env-overridable) -----------------------------------------
|
||||
|
||||
APP_URL = os.getenv("APP_URL", "").rstrip("/")
|
||||
INTERVAL_S = float(os.getenv("INTERVAL_S", "30"))
|
||||
TIMEOUT_S = float(os.getenv("TIMEOUT_S", "5"))
|
||||
ALERT_AFTER = int(os.getenv("ALERT_AFTER", "3"))
|
||||
|
||||
DEFAULT_DATA_FILE = Path(__file__).resolve().parent / "data" / "checks.json"
|
||||
DATA_FILE = Path(os.getenv("DATA_FILE", str(DEFAULT_DATA_FILE)))
|
||||
|
||||
# Keep the JSON array bounded so the dashboard stays fast and the file small.
|
||||
MAX_RECORDS = int(os.getenv("MAX_RECORDS", "2880")) # 24h at one check / 30s
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def fetch_json(url: str, timeout: float):
|
||||
"""GET a URL and parse JSON. Returns (status_code, parsed_body).
|
||||
|
||||
Raises on timeout, connection failure, or non-2xx status.
|
||||
"""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "ghaymah-monitor/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
body = None
|
||||
return resp.status, body
|
||||
|
||||
|
||||
def check_once(app_url: str) -> dict:
|
||||
"""Run one health + metrics check and return the record to persist."""
|
||||
health_url = f"{app_url}/health"
|
||||
metrics_url = f"{app_url}/metrics"
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
code, _body = fetch_json(health_url, TIMEOUT_S)
|
||||
latency_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
status = "up"
|
||||
except urllib.error.HTTPError as exc:
|
||||
# The app answered, just not with a healthy status — record the code.
|
||||
return {
|
||||
"ts": now_iso(),
|
||||
"status": "down",
|
||||
"code": exc.code,
|
||||
"latency_ms": round((time.perf_counter() - started) * 1000, 2),
|
||||
"requests": None,
|
||||
}
|
||||
except Exception:
|
||||
# Timeout, DNS failure, connection refused, TLS error, ...
|
||||
return {
|
||||
"ts": now_iso(),
|
||||
"status": "down",
|
||||
"code": None,
|
||||
"latency_ms": None,
|
||||
"requests": None,
|
||||
}
|
||||
|
||||
# /metrics is best-effort: a failure there must not turn a healthy app "down".
|
||||
requests_total = None
|
||||
try:
|
||||
_mcode, mbody = fetch_json(metrics_url, TIMEOUT_S)
|
||||
if isinstance(mbody, dict):
|
||||
value = mbody.get("requests_total")
|
||||
if isinstance(value, int):
|
||||
requests_total = value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ts": now_iso(),
|
||||
"status": status,
|
||||
"code": code,
|
||||
"latency_ms": latency_ms,
|
||||
"requests": requests_total,
|
||||
}
|
||||
|
||||
|
||||
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 append_record(path: Path, record: dict) -> None:
|
||||
"""Append one record to the JSON array, writing atomically."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
records = load_records(path)
|
||||
records.append(record)
|
||||
if len(records) > MAX_RECORDS:
|
||||
records = records[-MAX_RECORDS:]
|
||||
|
||||
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(record: dict) -> str:
|
||||
latency = f"{record['latency_ms']:.1f}ms" if record["latency_ms"] is not None else " - "
|
||||
code = record["code"] if record["code"] is not None else "---"
|
||||
reqs = record["requests"] if record["requests"] is not None else "-"
|
||||
return (
|
||||
f"{record['ts']} {record['status'].upper():<4} "
|
||||
f"code={code:<4} latency={latency:<9} requests={reqs}"
|
||||
)
|
||||
|
||||
|
||||
def run(app_url: str, once: bool) -> int:
|
||||
consecutive_failures = 0
|
||||
alerted = False
|
||||
|
||||
while True:
|
||||
record = check_once(app_url)
|
||||
append_record(DATA_FILE, record)
|
||||
print(format_line(record), flush=True)
|
||||
|
||||
if record["status"] == "down":
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= ALERT_AFTER and not alerted:
|
||||
print(
|
||||
f"ALERT: {app_url} has failed {consecutive_failures} consecutive "
|
||||
f"health checks (since {record['ts']})",
|
||||
flush=True,
|
||||
)
|
||||
alerted = True
|
||||
else:
|
||||
if alerted:
|
||||
print(f"RECOVERED: {app_url} is responding again at {record['ts']}", flush=True)
|
||||
consecutive_failures = 0
|
||||
alerted = False
|
||||
|
||||
if once:
|
||||
return 0 if record["status"] == "up" else 1
|
||||
|
||||
time.sleep(INTERVAL_S)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Monitor the Ghaymah-deployed API.")
|
||||
parser.add_argument("--once", action="store_true", help="run a single check and exit")
|
||||
parser.add_argument("--url", default=APP_URL, help="app base URL (default: $APP_URL)")
|
||||
args = parser.parse_args()
|
||||
|
||||
app_url = (args.url or "").rstrip("/")
|
||||
if not app_url:
|
||||
print(
|
||||
"error: no app URL. Set APP_URL, e.g.\n"
|
||||
" APP_URL=https://my-app.ghaymah.example python monitor.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
print(f"Monitoring {app_url} every {INTERVAL_S:g}s (timeout {TIMEOUT_S:g}s)", flush=True)
|
||||
print(f"Writing checks to {DATA_FILE}", flush=True)
|
||||
|
||||
try:
|
||||
return run(app_url, args.once)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
المرجع في مشكلة جديدة
حظر مستخدم