Complete Ghaymah cloud assessment

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

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 |

عرض الملف

@@ -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

عرض الملف

@@ -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"]

عرض الملف

@@ -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")

عرض الملف

@@ -0,0 +1,2 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0

عرض الملف

@@ -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>

عرض الملف

@@ -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
}
]

عرض الملف

@@ -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())