first commit
هذا الالتزام موجود في:
243
q5-mithal-monitor/dashboard.html
Normal file
243
q5-mithal-monitor/dashboard.html
Normal file
@@ -0,0 +1,243 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>لوحة مراقبة mithal.space</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0e14;
|
||||
--panel:#10161f;
|
||||
--border:#1c2530;
|
||||
--text:#dde5ee;
|
||||
--muted:#7b8a9e;
|
||||
--ok:#2fd480;
|
||||
--bad:#ef4f60;
|
||||
--warn:#f2b632;
|
||||
--accent:#5aa9ff;
|
||||
--mono:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{
|
||||
margin:0;background:var(--bg);color:var(--text);font-family:var(--mono);
|
||||
padding:26px 18px 50px;
|
||||
}
|
||||
.wrap{max-width:1000px;margin:0 auto;}
|
||||
header{display:flex;justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:10px;margin-bottom:20px;}
|
||||
h1{font-size:1.2rem;margin:0;}
|
||||
.target{color:var(--muted);font-size:.78rem;margin-top:4px;}
|
||||
.refresh{color:var(--muted);font-size:.72rem;}
|
||||
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin-bottom:14px;}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px 18px;}
|
||||
.label{color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;}
|
||||
|
||||
/* Uptime gauge */
|
||||
.gauge-card{display:flex;align-items:center;gap:16px;}
|
||||
.gauge{width:82px;height:82px;flex:0 0 auto;}
|
||||
.gauge-value{font-size:1.6rem;font-weight:700;}
|
||||
.gauge-sub{color:var(--muted);font-size:.72rem;}
|
||||
|
||||
.ssl-value{font-size:1.5rem;font-weight:700;}
|
||||
.ssl-badge{display:inline-block;padding:2px 8px;border-radius:5px;font-size:.68rem;margin-top:6px;}
|
||||
.ssl-badge.good{background:rgba(47,212,128,.15);color:var(--ok);}
|
||||
.ssl-badge.warn{background:rgba(242,182,50,.15);color:var(--warn);}
|
||||
.ssl-badge.bad{background:rgba(239,79,96,.15);color:var(--bad);}
|
||||
|
||||
.stat-row{display:flex;justify-content:space-between;font-size:.78rem;padding:4px 0;color:var(--muted);}
|
||||
.stat-row b{color:var(--text);}
|
||||
|
||||
.chart-card canvas{width:100%;height:160px;display:block;}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:.76rem;}
|
||||
th{text-align:right;color:var(--muted);font-weight:600;padding:8px 6px;border-bottom:1px solid var(--border);}
|
||||
td{padding:7px 6px;border-bottom:1px dashed var(--border);}
|
||||
.pill{display:inline-block;width:8px;height:8px;border-radius:50%;margin-left:6px;}
|
||||
.pill.up{background:var(--ok);}
|
||||
.pill.down{background:var(--bad);}
|
||||
|
||||
footer{text-align:center;color:var(--muted);font-size:.68rem;margin-top:18px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div>
|
||||
<h1>لوحة مراقبة mithal.space</h1>
|
||||
<div class="target">Latency · Uptime · SSL · DNS · Search Response</div>
|
||||
</div>
|
||||
<div class="refresh">آخر تحديث: <span id="lastUpdate">--</span></div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card gauge-card">
|
||||
<svg class="gauge" viewBox="0 0 42 42">
|
||||
<circle cx="21" cy="21" r="18" fill="none" stroke="#1c2530" stroke-width="4"></circle>
|
||||
<circle id="uptimeArc" cx="21" cy="21" r="18" fill="none" stroke="var(--ok)" stroke-width="4"
|
||||
stroke-dasharray="0 113" stroke-linecap="round" transform="rotate(-90 21 21)"></circle>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="label">Uptime (24 ساعة)</div>
|
||||
<div class="gauge-value" id="uptimeValue">--%</div>
|
||||
<div class="gauge-sub" id="uptimeChecks">-- فحص</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="label">شهادة SSL</div>
|
||||
<div class="ssl-value" id="sslDays">-- يوم</div>
|
||||
<div id="sslBadge" class="ssl-badge">--</div>
|
||||
<div class="stat-row"><span>تنتهي في</span><b id="sslExpiry">--</b></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="label">آخر قياس</div>
|
||||
<div class="stat-row"><span>Latency</span><b id="latestLatency">--</b></div>
|
||||
<div class="stat-row"><span>DNS</span><b id="latestDns">--</b></div>
|
||||
<div class="stat-row"><span>Search</span><b id="latestSearch">--</b></div>
|
||||
<div class="stat-row"><span>HTTP Status</span><b id="latestStatus">--</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card chart-card" style="margin-bottom:14px;">
|
||||
<div class="label">زمن الاستجابة (آخر ساعة)</div>
|
||||
<canvas id="latencyChart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="label">آخر 10 فحوصات</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>الوقت</th><th>الحالة</th><th>Latency</th><th>DNS</th><th>Search</th><th>SSL (أيام)</th></tr>
|
||||
</thead>
|
||||
<tbody id="logBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer>يقرأ من /api/metrics · يُحدَّث كل 30 ثانية</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '/api/metrics';
|
||||
const POLL_MS = 30000;
|
||||
|
||||
function fmtMs(v){ return (v == null) ? '--' : Math.round(v) + ' ms'; }
|
||||
function fmtTime(iso){
|
||||
if(!iso) return '--';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString('ar-EG', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
|
||||
}
|
||||
|
||||
function setUptimeGauge(percent){
|
||||
const circumference = 2 * Math.PI * 18; // ~113
|
||||
const arc = document.getElementById('uptimeArc');
|
||||
const p = percent == null ? 0 : percent;
|
||||
const filled = (p / 100) * circumference;
|
||||
arc.setAttribute('stroke-dasharray', `${filled} ${circumference}`);
|
||||
arc.style.stroke = p >= 99 ? 'var(--ok)' : (p >= 95 ? 'var(--warn)' : 'var(--bad)');
|
||||
}
|
||||
|
||||
function drawLatencyChart(series){
|
||||
const canvas = document.getElementById('latencyChart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
canvas.width = w * dpr; canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0,0,w,h);
|
||||
|
||||
if(!series || series.length < 2){
|
||||
ctx.fillStyle = '#3a4a5c';
|
||||
ctx.font = '12px monospace';
|
||||
ctx.fillText('بيانات غير كافية بعد...', 10, h/2);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = series.map(p => p.latency_ms);
|
||||
const max = Math.max(...values, 10) * 1.2;
|
||||
const stepX = w / (series.length - 1);
|
||||
|
||||
ctx.strokeStyle = '#5aa9ff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
values.forEach((v,i) => {
|
||||
const x = i * stepX;
|
||||
const y = h - (v / max) * (h - 10) - 5;
|
||||
i === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
ctx.lineTo((values.length-1)*stepX, h);
|
||||
ctx.lineTo(0, h);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(90,169,255,0.12)';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function renderSSL(ssl){
|
||||
const badge = document.getElementById('sslBadge');
|
||||
const days = ssl.days_remaining;
|
||||
document.getElementById('sslDays').textContent = (days == null) ? '--' : `${days} يوم`;
|
||||
document.getElementById('sslExpiry').textContent = ssl.expires_at ? new Date(ssl.expires_at).toLocaleDateString('ar-EG') : '--';
|
||||
|
||||
if(ssl.valid === false){
|
||||
badge.className = 'ssl-badge bad'; badge.textContent = 'غير صالحة';
|
||||
} else if(days == null){
|
||||
badge.className = 'ssl-badge'; badge.textContent = 'بلا بيانات';
|
||||
} else if(days > 14){
|
||||
badge.className = 'ssl-badge good'; badge.textContent = 'صالحة';
|
||||
} else if(days > 3){
|
||||
badge.className = 'ssl-badge warn'; badge.textContent = 'تقترب من الانتهاء';
|
||||
} else {
|
||||
badge.className = 'ssl-badge bad'; badge.textContent = 'خطر - قرب الانتهاء';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLog(checks){
|
||||
const body = document.getElementById('logBody');
|
||||
body.innerHTML = '';
|
||||
checks.forEach(c => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${fmtTime(c.timestamp)}</td>
|
||||
<td><span class="pill ${c.up ? 'up' : 'down'}"></span>${c.up ? 'يعمل' : 'متوقف'} (${c.status_code ?? '--'})</td>
|
||||
<td>${fmtMs(c.latency_ms)}</td>
|
||||
<td>${fmtMs(c.dns_ms)}</td>
|
||||
<td>${fmtMs(c.search_response_ms)}</td>
|
||||
<td>${c.ssl_days_remaining ?? '--'}</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function poll(){
|
||||
try{
|
||||
const res = await fetch(API, {cache:'no-store'});
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('uptimeValue').textContent = (data.uptime_24h_percent ?? '--') + '%';
|
||||
document.getElementById('uptimeChecks').textContent = `${data.checks_in_24h} فحص`;
|
||||
setUptimeGauge(data.uptime_24h_percent);
|
||||
|
||||
renderSSL(data.ssl || {});
|
||||
drawLatencyChart(data.latency_series);
|
||||
renderLog(data.last_checks || []);
|
||||
|
||||
const latest = data.latest || {};
|
||||
document.getElementById('latestLatency').textContent = fmtMs(latest.latency_ms);
|
||||
document.getElementById('latestDns').textContent = fmtMs(latest.dns_ms);
|
||||
document.getElementById('latestSearch').textContent = fmtMs(latest.search_response_ms);
|
||||
document.getElementById('latestStatus').textContent = latest.status_code ?? '--';
|
||||
|
||||
document.getElementById('lastUpdate').textContent = fmtTime(data.generated_at);
|
||||
}catch(err){
|
||||
document.getElementById('lastUpdate').textContent = 'خطأ في الاتصال';
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
setInterval(poll, POLL_MS);
|
||||
window.addEventListener('resize', () => poll());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
320
q5-mithal-monitor/monitor.py
Normal file
320
q5-mithal-monitor/monitor.py
Normal file
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
monitor.py
|
||||
==========
|
||||
سكربت مراقبة ذاتي الاكتفاء (بدون أي مكتبات خارجية) لموقع mithal.space.
|
||||
|
||||
يقوم بأمرين معًا:
|
||||
1. تشغيل خمسة فحوصات كل 60 ثانية (Latency, Uptime, SSL, DNS, Search)
|
||||
وتخزين النتائج في ملف JSON Lines محلي (data.jsonl).
|
||||
2. تشغيل خادم HTTP بسيط يقدّم dashboard.html ويعرض بيانات مجمّعة
|
||||
عبر /api/metrics حتى تعمل اللوحة مباشرة بفتح المتصفح على الخادم.
|
||||
|
||||
التشغيل:
|
||||
python monitor.py
|
||||
python monitor.py --interval 60 --port 8080
|
||||
python monitor.py --once # فحص واحد فقط بدون تشغيل خادم (مناسب لجدولة cron)
|
||||
|
||||
النشر على غيمة (ghaymah.systems):
|
||||
هذا الملف مستقل تمامًا (لا يحتاج pip install لأي حزمة)، لذا يمكن نشره
|
||||
داخل أي حاوية Python رسمية بأمر تشغيل واحد:
|
||||
FROM python:3.11-slim
|
||||
COPY monitor.py dashboard.html /app/
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
CMD ["python", "monitor.py"]
|
||||
اضبط Health Check Path على /health ومنفذ الخدمة على 8080 من console غيمة.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, urlencode
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# الإعدادات (قابلة للتعديل عبر متغيرات البيئة)
|
||||
# --------------------------------------------------------------------------
|
||||
TARGET_URL = os.environ.get("TARGET_URL", "https://mithal.space")
|
||||
SEARCH_PATH = os.environ.get("SEARCH_PATH", "/search")
|
||||
SEARCH_QUERY_PARAM = os.environ.get("SEARCH_QUERY_PARAM", "q")
|
||||
SEARCH_QUERY_VALUE = os.environ.get("SEARCH_QUERY_VALUE", "test")
|
||||
REQUEST_TIMEOUT = float(os.environ.get("REQUEST_TIMEOUT", 10))
|
||||
DATA_FILE = os.environ.get("DATA_FILE", "data.jsonl")
|
||||
MAX_RECORDS = int(os.environ.get("MAX_RECORDS", 3000)) # ~50 ساعة عند فحص كل دقيقة
|
||||
|
||||
_parsed = urlparse(TARGET_URL)
|
||||
HOSTNAME = _parsed.hostname
|
||||
PORT_443 = 443
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DASHBOARD_FILE = os.path.join(BASE_DIR, "dashboard.html")
|
||||
|
||||
_store_lock = threading.Lock()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1) الفحوصات الخمسة
|
||||
# --------------------------------------------------------------------------
|
||||
def check_dns(hostname: str = HOSTNAME) -> dict:
|
||||
"""يقيس زمن تحليل اسم النطاق (DNS resolution)."""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
socket.getaddrinfo(hostname, PORT_443)
|
||||
return {"ok": True, "dns_ms": round((time.perf_counter() - start) * 1000, 2)}
|
||||
except socket.gaierror as e:
|
||||
return {"ok": False, "dns_ms": round((time.perf_counter() - start) * 1000, 2), "error": str(e)}
|
||||
|
||||
|
||||
def check_ssl(hostname: str = HOSTNAME, port: int = 443) -> dict:
|
||||
"""يفحص صلاحية شهادة SSL وتاريخ انتهائها."""
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
with socket.create_connection((hostname, port), timeout=REQUEST_TIMEOUT) as sock:
|
||||
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
|
||||
cert = ssock.getpeercert()
|
||||
not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
|
||||
days_remaining = (not_after - datetime.now(timezone.utc)).days
|
||||
return {"ok": True, "valid": True, "expires_at": not_after.isoformat(), "days_remaining": days_remaining}
|
||||
except ssl.SSLCertVerificationError as e:
|
||||
return {"ok": True, "valid": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "valid": False, "error": str(e)}
|
||||
|
||||
|
||||
def _timed_get(url: str):
|
||||
"""طلب GET بسيط عبر urllib مع قياس الزمن وإرجاع (status_code, elapsed_ms, error)."""
|
||||
start = time.perf_counter()
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "mithal-monitor/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
|
||||
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||
return resp.getcode(), elapsed_ms, None
|
||||
except urllib.error.HTTPError as e:
|
||||
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||
return e.code, elapsed_ms, None # كود HTTP فعلي (مثل 404/500) وليس خطأ اتصال
|
||||
except Exception as e:
|
||||
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||
return None, elapsed_ms, str(e)
|
||||
|
||||
|
||||
def check_latency(url: str = TARGET_URL) -> dict:
|
||||
"""يقيس زمن استجابة الصفحة الرئيسية ويحدد حالة توفر الموقع (uptime)."""
|
||||
status_code, elapsed_ms, error = _timed_get(url)
|
||||
up = status_code is not None and 200 <= status_code < 400
|
||||
return {"ok": error is None, "up": up, "status_code": status_code, "latency_ms": elapsed_ms, "error": error}
|
||||
|
||||
|
||||
def check_search(base_url: str = TARGET_URL) -> dict:
|
||||
"""يرسل استعلام بحث فعلي ويقيس زمن الرد بمعزل عن الصفحة الرئيسية."""
|
||||
query = urlencode({SEARCH_QUERY_PARAM: SEARCH_QUERY_VALUE})
|
||||
search_url = base_url.rstrip("/") + SEARCH_PATH + "?" + query
|
||||
status_code, elapsed_ms, error = _timed_get(search_url)
|
||||
return {"ok": error is None, "status_code": status_code, "search_response_ms": elapsed_ms, "error": error}
|
||||
|
||||
|
||||
def run_full_check() -> dict:
|
||||
"""ينفّذ جميع الفحوصات الخمسة ويجمعها في سجل واحد جاهز للتخزين."""
|
||||
dns_result = check_dns()
|
||||
latency_result = check_latency()
|
||||
ssl_result = check_ssl()
|
||||
search_result = check_search()
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"target": TARGET_URL,
|
||||
"up": latency_result.get("up", False),
|
||||
"status_code": latency_result.get("status_code"),
|
||||
"latency_ms": latency_result.get("latency_ms"),
|
||||
"dns_ms": dns_result.get("dns_ms"),
|
||||
"ssl_valid": ssl_result.get("valid"),
|
||||
"ssl_days_remaining": ssl_result.get("days_remaining"),
|
||||
"ssl_expires_at": ssl_result.get("expires_at"),
|
||||
"search_response_ms": search_result.get("search_response_ms"),
|
||||
"search_status_code": search_result.get("status_code"),
|
||||
"error": latency_result.get("error") or ssl_result.get("error") or search_result.get("error"),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2) التخزين (JSON Lines) - قابل للتبديل بسهولة إلى append_to_csv أدناه
|
||||
# --------------------------------------------------------------------------
|
||||
def append_to_store(record: dict, path: str = DATA_FILE, max_lines: int = MAX_RECORDS):
|
||||
"""يضيف سجل فحص جديد كسطر JSON، ويقلّم الملف عند تجاوز max_lines."""
|
||||
with _store_lock:
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
if len(lines) > max_lines:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines[-max_lines:])
|
||||
|
||||
|
||||
def append_to_csv(record: dict, path: str = "data.csv"):
|
||||
"""بديل اختياري: تخزين نفس السجل بصيغة CSV مسطّحة (جدول بيانات)."""
|
||||
import csv
|
||||
file_exists = os.path.exists(path)
|
||||
with _store_lock:
|
||||
with open(path, "a", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=list(record.keys()))
|
||||
if not file_exists:
|
||||
writer.writeheader()
|
||||
writer.writerow(record)
|
||||
|
||||
|
||||
def read_all_records(path: str = DATA_FILE) -> list:
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
records = []
|
||||
with _store_lock:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3) التجميع لأجل /api/metrics (uptime 24h, latency آخر ساعة، SSL، آخر 10)
|
||||
# --------------------------------------------------------------------------
|
||||
def build_metrics_payload() -> dict:
|
||||
records = read_all_records()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
def parse_ts(r):
|
||||
try:
|
||||
return datetime.fromisoformat(r["timestamp"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
last_24h = [r for r in records if (ts := parse_ts(r)) and now - ts <= timedelta(hours=24)]
|
||||
uptime_percent = round(100 * sum(1 for r in last_24h if r.get("up")) / len(last_24h), 2) if last_24h else None
|
||||
|
||||
last_hour = [r for r in records if (ts := parse_ts(r)) and now - ts <= timedelta(hours=1)]
|
||||
latency_series = [
|
||||
{"timestamp": r["timestamp"], "latency_ms": r.get("latency_ms")}
|
||||
for r in last_hour if r.get("latency_ms") is not None
|
||||
]
|
||||
|
||||
latest = records[-1] if records else {}
|
||||
ssl_info = {
|
||||
"valid": latest.get("ssl_valid"),
|
||||
"days_remaining": latest.get("ssl_days_remaining"),
|
||||
"expires_at": latest.get("ssl_expires_at"),
|
||||
}
|
||||
|
||||
last_10 = records[-10:][::-1]
|
||||
|
||||
return {
|
||||
"uptime_24h_percent": uptime_percent,
|
||||
"checks_in_24h": len(last_24h),
|
||||
"latency_series": latency_series,
|
||||
"ssl": ssl_info,
|
||||
"last_checks": last_10,
|
||||
"latest": latest,
|
||||
"generated_at": now.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4) حلقة المراقبة في الخلفية (تعمل كل CHECK_INTERVAL ثانية)
|
||||
# --------------------------------------------------------------------------
|
||||
def collector_loop(interval: int):
|
||||
while True:
|
||||
try:
|
||||
record = run_full_check()
|
||||
append_to_store(record)
|
||||
status = "UP" if record["up"] else "DOWN"
|
||||
print(f"[{record['timestamp']}] {status} http={record['status_code']} "
|
||||
f"latency={record['latency_ms']}ms dns={record['dns_ms']}ms "
|
||||
f"ssl_days={record['ssl_days_remaining']} search={record['search_response_ms']}ms")
|
||||
if record.get("error"):
|
||||
print(f" -> ملاحظة: {record['error']}")
|
||||
except Exception as e:
|
||||
print(f"[collector] خطأ غير متوقع: {e}")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5) خادم HTTP بسيط (بدون Flask) يقدّم dashboard.html + /api/metrics + /health
|
||||
# --------------------------------------------------------------------------
|
||||
class DashboardHandler(BaseHTTPRequestHandler):
|
||||
def _send_json(self, payload: dict, status: int = 200):
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path in ("/", "/dashboard.html"):
|
||||
if os.path.exists(DASHBOARD_FILE):
|
||||
with open(DASHBOARD_FILE, "rb") as f:
|
||||
body = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self._send_json({"error": "dashboard.html not found next to monitor.py"}, 404)
|
||||
elif self.path == "/health":
|
||||
self._send_json({"status": "healthy"})
|
||||
elif self.path == "/api/metrics":
|
||||
self._send_json(build_metrics_payload())
|
||||
else:
|
||||
self._send_json({"error": "not found"}, 404)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass # تعطيل سجلات الخادم الافتراضية المزعجة؛ نطبع فقط سجلات الفحص
|
||||
|
||||
|
||||
def run_server(port: int):
|
||||
server = ThreadingHTTPServer(("0.0.0.0", port), DashboardHandler)
|
||||
print(f"Dashboard server running on http://0.0.0.0:{port} (health: /health, api: /api/metrics)")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# نقطة الدخول
|
||||
# --------------------------------------------------------------------------
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Monitor mithal.space + serve dashboard.")
|
||||
parser.add_argument("--interval", type=int, default=60, help="ثواني بين كل فحص (افتراضي 60)")
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8080)))
|
||||
parser.add_argument("--once", action="store_true", help="نفّذ فحصًا واحدًا فقط بدون تشغيل الخادم (مناسب لـ cron)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.once:
|
||||
record = run_full_check()
|
||||
append_to_store(record)
|
||||
print(json.dumps(record, indent=2, ensure_ascii=False))
|
||||
return
|
||||
|
||||
collector_thread = threading.Thread(target=collector_loop, args=(args.interval,), daemon=True)
|
||||
collector_thread.start()
|
||||
|
||||
run_server(args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nتم إيقاف المراقبة بواسطة المستخدم.")
|
||||
المرجع في مشكلة جديدة
حظر مستخدم