first commit
هذا الالتزام موجود في:
10
q5-mithal-monitor/.dockerignore
Normal file
10
q5-mithal-monitor/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.git
|
||||
.gitignore
|
||||
screenshots
|
||||
*.log
|
||||
venv
|
||||
.env
|
||||
.DS_Store
|
||||
46
q5-mithal-monitor/Dockerfile
Normal file
46
q5-mithal-monitor/Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
||||
# ============================================================
|
||||
# Mithal.space Monitor — Docker Image
|
||||
# Serves the dashboard via Nginx and runs the monitor in background
|
||||
# ============================================================
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Prevent Python from buffering stdout/stderr
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install system deps for nginx
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends nginx curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Remove default nginx site
|
||||
RUN rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies first (layer caching)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY monitor.py .
|
||||
COPY dashboard.html /usr/share/nginx/html/index.html
|
||||
COPY style.css /usr/share/nginx/html/
|
||||
COPY script.js /usr/share/nginx/html/
|
||||
COPY metrics.json /usr/share/nginx/html/metrics.json
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Create log directory
|
||||
RUN mkdir -p /var/log/nginx && \
|
||||
touch /app/monitor.log
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start script: run monitor in background, nginx in foreground
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
136
q5-mithal-monitor/README.md
Normal file
136
q5-mithal-monitor/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Mithal.space Monitor
|
||||
|
||||
Production-quality website monitoring solution for [mithal.space](https://mithal.space) — an Arabic-first, privacy-respecting search engine.
|
||||
|
||||
## Overview
|
||||
|
||||
This project provides continuous monitoring of mithal.space, collecting uptime, latency, DNS, SSL, and search-response metrics every 60 seconds. A static HTML dashboard displays real-time charts and status cards, auto-refreshing every 30 seconds.
|
||||
|
||||
## Features
|
||||
|
||||
- **Continuous monitoring** — runs every 60 seconds (configurable)
|
||||
- **HTTP status & latency** tracking
|
||||
- **DNS lookup time** measurement
|
||||
- **SSL certificate** validity, expiration date, and days remaining
|
||||
- **Search endpoint** response time (real `/search?q=` request)
|
||||
- **JSON persistence** — last 24 hours of data (1,440 records)
|
||||
- **Static dashboard** — no frameworks, just HTML/CSS/Vanilla JS
|
||||
- **Chart.js** line and bar charts
|
||||
- **Dark mode** toggle
|
||||
- **Auto-refresh** with countdown timer
|
||||
- **CSV export** of all metrics
|
||||
- **CLI arguments** for interval, target, max records
|
||||
- **Logging** to both console and `monitor.log`
|
||||
- **Never crashes** — all errors handled gracefully
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- pip
|
||||
- A modern web browser (for the dashboard)
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone / Download
|
||||
|
||||
```bash
|
||||
cd q5-mithal-monitor
|
||||
```
|
||||
|
||||
### 2. Create a Python Virtual Environment
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Linux / macOS
|
||||
# venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Run the Monitor
|
||||
|
||||
```bash
|
||||
python monitor.py
|
||||
```
|
||||
|
||||
With options:
|
||||
|
||||
```bash
|
||||
python monitor.py --interval 30 --target https://mithal.space --max-records 720
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--interval` | `60` | Check interval in seconds |
|
||||
| `--target` | `https://mithal.space` | URL to monitor |
|
||||
| `--max-records` | `1440` | Max records to keep (≈24h at 60s) |
|
||||
| `--search-query` | `test` | Query sent to `/search` endpoint |
|
||||
| `--metrics-file` | `metrics.json` | Path to metrics file |
|
||||
|
||||
### 5. View the Dashboard
|
||||
|
||||
Open `dashboard.html` in a browser. It reads `metrics.json` directly via `fetch()`.
|
||||
|
||||
```bash
|
||||
# Option A: just open the file
|
||||
open dashboard.html # macOS
|
||||
xdg-open dashboard.html # Linux
|
||||
|
||||
# Option B: serve via Python
|
||||
python3 -m http.server 8080
|
||||
# then visit http://localhost:8080/dashboard.html
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
q5-mithal-monitor/
|
||||
├── monitor.py # Main monitoring script
|
||||
├── metrics.json # Collected metrics (auto-generated)
|
||||
├── dashboard.html # Dashboard page
|
||||
├── style.css # Dashboard styles
|
||||
├── script.js # Dashboard logic
|
||||
├── requirements.txt # Python dependencies
|
||||
├── README.md # This file
|
||||
├── monitor.log # Log file (auto-generated)
|
||||
└── screenshots/
|
||||
├── dashboard.png
|
||||
└── monitor.png
|
||||
```
|
||||
|
||||
## How Monitoring Works
|
||||
|
||||
Every check interval the script:
|
||||
|
||||
1. **DNS** — resolves the hostname and measures lookup time
|
||||
2. **SSL** — connects on port 443 and reads the certificate expiry
|
||||
3. **HTTP** — sends `GET` to the target URL, records status code and latency
|
||||
4. **Search** — sends `GET /search?q=test` to measure search endpoint response
|
||||
5. **Persist** — appends the record to `metrics.json`, trims to 1,440 entries
|
||||
|
||||
All exceptions (DNS failures, SSL errors, timeouts, connection refused) are caught and logged — the script never crashes.
|
||||
|
||||
## Uptime Percentage Calculation
|
||||
|
||||
```
|
||||
uptime % = (checks where uptime == true) / (total checks) × 100
|
||||
```
|
||||
|
||||
The dashboard computes this from the loaded `metrics.json` data. When the file contains ≤1,440 records, the percentage reflects all available data; otherwise it represents the last 24 hours.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Single-target** — monitors only one URL per instance
|
||||
- **No alerting** — no email/Slack/webhook notifications (designed for visual monitoring)
|
||||
- **No auth** — dashboard has no authentication; serve behind a reverse proxy for production
|
||||
- **Local file** — `metrics.json` is read via browser `fetch()`; requires same-origin or local file access
|
||||
- **Chart.js CDN** — the dashboard loads Chart.js from a CDN; works offline after first load (cached)
|
||||
- **Search metric** — only measures response time, not result quality or completeness
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
ثنائية
q5-mithal-monitor/__pycache__/monitor.cpython-312.pyc
Normal file
ثنائية
q5-mithal-monitor/__pycache__/monitor.cpython-312.pyc
Normal file
ملف ثنائي غير معروض.
113
q5-mithal-monitor/dashboard.html
Normal file
113
q5-mithal-monitor/dashboard.html
Normal file
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mithal.space Monitor Dashboard</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dashboard">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="header-title">Mithal.space Monitor</h1>
|
||||
<span class="header-subtitle">Real-time Website Health</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="themeToggle" class="theme-toggle" title="Toggle dark mode">
|
||||
<span class="icon-sun">☼</span>
|
||||
<span class="icon-moon">☾</span>
|
||||
</button>
|
||||
<div class="refresh-info">
|
||||
<span id="countdown" class="countdown">30s</span>
|
||||
<button id="refreshBtn" class="refresh-btn" title="Refresh now">↻</button>
|
||||
</div>
|
||||
<button id="exportCsv" class="export-btn" title="Export CSV">↧ CSV</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loadingIndicator" class="loading-indicator">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading metrics...</span>
|
||||
</div>
|
||||
|
||||
<!-- Status Cards -->
|
||||
<section class="cards">
|
||||
<div class="card" id="cardStatus">
|
||||
<div class="card-label">Current Status</div>
|
||||
<div class="card-value" id="currentStatus">--</div>
|
||||
<div class="card-indicator" id="statusIndicator"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">HTTP Latency</div>
|
||||
<div class="card-value" id="currentLatency">--</div>
|
||||
<div class="card-unit">ms</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">DNS Lookup</div>
|
||||
<div class="card-value" id="currentDns">--</div>
|
||||
<div class="card-unit">ms</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">SSL Remaining</div>
|
||||
<div class="card-value" id="currentSsl">--</div>
|
||||
<div class="card-unit">days</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Search Response</div>
|
||||
<div class="card-value" id="currentSearch">--</div>
|
||||
<div class="card-unit">ms</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Uptime (24h)</div>
|
||||
<div class="card-value" id="uptimePercent">--</div>
|
||||
<div class="card-unit">%</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Charts -->
|
||||
<section class="charts">
|
||||
<div class="chart-container">
|
||||
<h3>Latency (Last Hour)</h3>
|
||||
<canvas id="latencyChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h3>Search Response Time</h3>
|
||||
<canvas id="searchChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h3>DNS Lookup Time</h3>
|
||||
<canvas id="dnsChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Table -->
|
||||
<section class="table-section">
|
||||
<h3>Latest 10 Checks</h3>
|
||||
<div class="table-wrapper">
|
||||
<table id="metricsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>HTTP</th>
|
||||
<th>Latency (ms)</th>
|
||||
<th>DNS (ms)</th>
|
||||
<th>SSL Remaining</th>
|
||||
<th>Search (ms)</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="metricsBody">
|
||||
<tr><td colspan="7" class="no-data">No data available</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
q5-mithal-monitor/entrypoint.sh
Normal file
16
q5-mithal-monitor/entrypoint.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "============================================"
|
||||
echo " Mithal.space Monitor — Starting"
|
||||
echo "============================================"
|
||||
|
||||
# Start monitor in background
|
||||
echo "[entrypoint] Starting monitor.py..."
|
||||
python /app/monitor.py &
|
||||
MONITOR_PID=$!
|
||||
echo "[entrypoint] Monitor started (PID: $MONITOR_PID)"
|
||||
|
||||
# Start nginx in foreground
|
||||
echo "[entrypoint] Starting Nginx..."
|
||||
exec nginx -g "daemon off;"
|
||||
15
q5-mithal-monitor/metrics.json
Normal file
15
q5-mithal-monitor/metrics.json
Normal file
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-07-27T19:40:10.244751Z",
|
||||
"status_code": 200,
|
||||
"uptime": true,
|
||||
"latency_ms": 1525.44,
|
||||
"dns_ms": 66.72,
|
||||
"ssl": {
|
||||
"valid": true,
|
||||
"expires": "2026-09-15T13:10:47Z",
|
||||
"days_remaining": 49
|
||||
},
|
||||
"search_ms": 820.44
|
||||
}
|
||||
]
|
||||
0
q5-mithal-monitor/monitor.log
Normal file
0
q5-mithal-monitor/monitor.log
Normal file
404
q5-mithal-monitor/monitor.py
Normal file
404
q5-mithal-monitor/monitor.py
Normal file
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mithal.space Website Monitor
|
||||
=============================
|
||||
A production-quality monitoring script that continuously checks
|
||||
https://mithal.space for uptime, latency, DNS, SSL, and search performance.
|
||||
|
||||
Usage:
|
||||
python monitor.py
|
||||
python monitor.py --interval 30
|
||||
python monitor.py --interval 120 --target https://mithal.space --max-records 720
|
||||
|
||||
Author: SRE Team
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_TARGET_URL: str = "https://mithal.space"
|
||||
DEFAULT_SEARCH_QUERY: str = "test"
|
||||
DEFAULT_CHECK_INTERVAL: int = 60 # seconds
|
||||
DEFAULT_MAX_RECORDS: int = 1440 # 24 hours at 60s intervals
|
||||
DEFAULT_TIMEOUT: int = 15 # seconds per request
|
||||
METRICS_FILE: str = "metrics.json"
|
||||
LOG_FILE: str = "monitor.log"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging Setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup_logging(log_file: str = LOG_FILE, level: int = logging.INFO) -> logging.Logger:
|
||||
"""Configure and return the application logger."""
|
||||
logger = logging.getLogger("mithal_monitor")
|
||||
logger.setLevel(level)
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(level)
|
||||
console_fmt = logging.Formatter(
|
||||
"[%(asctime)s] %(levelname)-8s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
console_handler.setFormatter(console_fmt)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# File handler
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setLevel(level)
|
||||
file_fmt = logging.Formatter(
|
||||
"[%(asctime)s] %(levelname)-8s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
file_handler.setFormatter(file_fmt)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP Session Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_session() -> requests.Session:
|
||||
"""Create a requests session with retry and timeout defaults."""
|
||||
session = requests.Session()
|
||||
retries = Retry(
|
||||
total=2,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[502, 503, 504],
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retries)
|
||||
session.mount("https://", adapter)
|
||||
session.mount("http://", adapter)
|
||||
return session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metric Collection Functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_dns(hostname: str) -> float:
|
||||
"""
|
||||
Measure DNS lookup time in milliseconds.
|
||||
|
||||
Returns the time taken to resolve the hostname.
|
||||
Raises SocketError on failure.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
socket.getaddrinfo(hostname, None)
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
return round(elapsed_ms, 2)
|
||||
|
||||
|
||||
def check_ssl(hostname: str) -> dict[str, Any]:
|
||||
"""
|
||||
Retrieve SSL certificate details for the given hostname.
|
||||
|
||||
Returns a dict with:
|
||||
- valid: bool
|
||||
- expires: ISO timestamp string or None
|
||||
- days_remaining: int or None
|
||||
"""
|
||||
result: dict[str, Any] = {"valid": False, "expires": None, "days_remaining": None}
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
with socket.create_connection((hostname, 443), timeout=DEFAULT_TIMEOUT) as sock:
|
||||
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
|
||||
cert = ssock.getpeercert()
|
||||
if not cert:
|
||||
return result
|
||||
|
||||
not_after = cert.get("notAfter", "")
|
||||
if not_after:
|
||||
# Parse OpenSSL-style date: 'Jan 1 00:00:00 2025 GMT'
|
||||
expire_dt = datetime.datetime.strptime(
|
||||
not_after, "%b %d %H:%M:%S %Y %Z"
|
||||
)
|
||||
now = datetime.datetime.utcnow()
|
||||
days_left = (expire_dt - now).days
|
||||
|
||||
result["valid"] = days_left > 0
|
||||
result["expires"] = expire_dt.isoformat() + "Z"
|
||||
result["days_remaining"] = days_left
|
||||
except (ssl.SSLError, socket.error, OSError) as exc:
|
||||
logger.warning("SSL check failed for %s: %s", hostname, exc)
|
||||
result["valid"] = False
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_http(session: requests.Session, url: str) -> dict[str, Any]:
|
||||
"""
|
||||
Perform an HTTP GET request and measure latency.
|
||||
|
||||
Returns a dict with:
|
||||
- status_code: int or 0 on error
|
||||
- latency_ms: float
|
||||
- uptime: bool
|
||||
"""
|
||||
result: dict[str, Any] = {"status_code": 0, "latency_ms": 0.0, "uptime": False}
|
||||
|
||||
try:
|
||||
start = time.monotonic()
|
||||
resp = session.get(url, timeout=DEFAULT_TIMEOUT, allow_redirects=True)
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
|
||||
result["status_code"] = resp.status_code
|
||||
result["latency_ms"] = round(elapsed_ms, 2)
|
||||
result["uptime"] = 200 <= resp.status_code < 400
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("HTTP request timed out for %s", url)
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
logger.warning("Connection error for %s: %s", url, exc)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.warning("HTTP request failed for %s: %s", url, exc)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_search_response(
|
||||
session: requests.Session, base_url: str, query: str
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Measure search endpoint response time in milliseconds.
|
||||
|
||||
mithal.space exposes a public search endpoint at /search?q=<query>.
|
||||
We send a real search request and measure the response time.
|
||||
|
||||
Returns:
|
||||
Response time in milliseconds, or None on failure.
|
||||
"""
|
||||
search_url = f"{base_url}/search?q={query}"
|
||||
|
||||
try:
|
||||
start = time.monotonic()
|
||||
resp = session.get(search_url, timeout=DEFAULT_TIMEOUT, allow_redirects=True)
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
|
||||
if resp.status_code == 200:
|
||||
return round(elapsed_ms, 2)
|
||||
logger.warning("Search endpoint returned status %d", resp.status_code)
|
||||
return None
|
||||
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.warning("Search request failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metrics Persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_metrics(metrics_path: str) -> list[dict[str, Any]]:
|
||||
"""Load existing metrics from the JSON file."""
|
||||
if not os.path.exists(metrics_path):
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(metrics_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
except (json.JSONDecodeError, IOError) as exc:
|
||||
logger.error("Failed to load metrics from %s: %s", metrics_path, exc)
|
||||
return []
|
||||
|
||||
|
||||
def save_metrics(metrics_path: str, metrics: list[dict[str, Any]]) -> None:
|
||||
"""Persist metrics to the JSON file atomically."""
|
||||
tmp_path = metrics_path + ".tmp"
|
||||
try:
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(metrics, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_path, metrics_path)
|
||||
except IOError as exc:
|
||||
logger.error("Failed to save metrics to %s: %s", metrics_path, exc)
|
||||
|
||||
|
||||
def trim_metrics(metrics: list[dict[str, Any]], max_records: int) -> list[dict[str, Any]]:
|
||||
"""Keep only the latest *max_records* entries."""
|
||||
if len(metrics) > max_records:
|
||||
return metrics[-max_records:]
|
||||
return metrics
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single Check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def perform_check(
|
||||
session: requests.Session,
|
||||
target_url: str,
|
||||
search_query: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run one full monitoring check and return a metrics dict.
|
||||
|
||||
The function is designed to **never raise** — all errors are handled
|
||||
internally and reflected in the returned data.
|
||||
"""
|
||||
parsed = urlparse(target_url)
|
||||
hostname = parsed.hostname or "mithal.space"
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
timestamp = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
# DNS
|
||||
dns_ms: Optional[float] = None
|
||||
try:
|
||||
dns_ms = check_dns(hostname)
|
||||
except Exception as exc:
|
||||
logger.error("DNS lookup failed: %s", exc)
|
||||
|
||||
# SSL
|
||||
ssl_info = check_ssl(hostname)
|
||||
|
||||
# HTTP
|
||||
http_info = check_http(session, target_url)
|
||||
|
||||
# Search
|
||||
search_ms = check_search_response(session, base_url, search_query)
|
||||
|
||||
record: dict[str, Any] = {
|
||||
"timestamp": timestamp,
|
||||
"status_code": http_info["status_code"],
|
||||
"uptime": http_info["uptime"],
|
||||
"latency_ms": http_info["latency_ms"],
|
||||
"dns_ms": dns_ms,
|
||||
"ssl": ssl_info,
|
||||
"search_ms": search_ms,
|
||||
}
|
||||
|
||||
return record
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Argument Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Monitor https://mithal.space uptime, latency, DNS, SSL, and search.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" python monitor.py\n"
|
||||
" python monitor.py --interval 30\n"
|
||||
" python monitor.py --interval 120 --max-records 720\n"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=int,
|
||||
default=DEFAULT_CHECK_INTERVAL,
|
||||
help=f"Check interval in seconds (default: {DEFAULT_CHECK_INTERVAL})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
type=str,
|
||||
default=DEFAULT_TARGET_URL,
|
||||
help=f"Target URL to monitor (default: {DEFAULT_TARGET_URL})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-records",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_RECORDS,
|
||||
help=f"Maximum number of records to keep (default: {DEFAULT_MAX_RECORDS})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--search-query",
|
||||
type=str,
|
||||
default=DEFAULT_SEARCH_QUERY,
|
||||
help=f"Search query to use for endpoint testing (default: {DEFAULT_SEARCH_QUERY})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--metrics-file",
|
||||
type=str,
|
||||
default=METRICS_FILE,
|
||||
help=f"Path to the metrics JSON file (default: {METRICS_FILE})",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main Loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point: run the monitoring loop."""
|
||||
args = parse_args()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Mithal.space Monitor started")
|
||||
logger.info("Target URL : %s", args.target)
|
||||
logger.info("Search query : %s", args.search_query)
|
||||
logger.info("Check interval : %d seconds", args.interval)
|
||||
logger.info("Max records : %d", args.max_records)
|
||||
logger.info("Metrics file : %s", args.metrics_file)
|
||||
logger.info("Log file : %s", LOG_FILE)
|
||||
logger.info("=" * 60)
|
||||
|
||||
session = create_session()
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
record = perform_check(session, args.target, args.search_query)
|
||||
|
||||
# Load, append, trim, save
|
||||
metrics = load_metrics(args.metrics_file)
|
||||
metrics.append(record)
|
||||
metrics = trim_metrics(metrics, args.max_records)
|
||||
save_metrics(args.metrics_file, metrics)
|
||||
|
||||
# Summary log
|
||||
status_emoji = "✓" if record["uptime"] else "✗"
|
||||
logger.info(
|
||||
"%s status=%d latency=%.0fms dns=%.0fms ssl_days=%s search=%s",
|
||||
status_emoji,
|
||||
record["status_code"],
|
||||
record["latency_ms"],
|
||||
record["dns_ms"] or 0,
|
||||
record["ssl"]["days_remaining"] if record["ssl"] else "N/A",
|
||||
f"{record['search_ms']:.0f}ms" if record["search_ms"] else "N/A",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error during check: %s", exc)
|
||||
|
||||
time.sleep(args.interval)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Monitor stopped by user (Ctrl+C)")
|
||||
finally:
|
||||
session.close()
|
||||
logger.info("Session closed. Goodbye.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
q5-mithal-monitor/nginx.conf
Normal file
23
q5-mithal-monitor/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Allow metrics.json to be fetched by the dashboard
|
||||
location = /metrics.json {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 'OK';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
2
q5-mithal-monitor/requirements.txt
Normal file
2
q5-mithal-monitor/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
requests>=2.31.0,<3.0.0
|
||||
urllib3>=2.0.0,<3.0.0
|
||||
145
q5-mithal-monitor/screenshots/dashboard-preview.svg
Normal file
145
q5-mithal-monitor/screenshots/dashboard-preview.svg
Normal file
@@ -0,0 +1,145 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 520" font-family="Segoe UI, Arial, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="dbg" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0f172a"/>
|
||||
<stop offset="100%" style="stop-color:#1e293b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="900" height="520" fill="url(#dbg)" rx="12"/>
|
||||
|
||||
<!-- Title bar -->
|
||||
<rect x="0" y="0" width="900" height="50" fill="#181b24" rx="12"/>
|
||||
<rect x="0" y="20" width="900" height="30" fill="#181b24"/>
|
||||
<text x="30" y="33" fill="#e2e8f0" font-size="16" font-weight="700">Mithal.space Monitor</text>
|
||||
<text x="200" y="33" fill="#64748b" font-size="11">Real-time Website Health</text>
|
||||
<circle cx="820" cy="25" r="8" fill="#22c55e"/>
|
||||
<text x="835" y="29" fill="#94a3b8" font-size="10">Live</text>
|
||||
<text x="870" y="29" fill="#94a3b8" font-size="10">30s</text>
|
||||
|
||||
<!-- Status Cards Row -->
|
||||
<!-- Card 1: Status -->
|
||||
<rect x="20" y="65" width="140" height="80" rx="10" fill="#1e293b" stroke="#22c55e" stroke-width="1.5"/>
|
||||
<text x="35" y="85" fill="#64748b" font-size="9" font-weight="600" text-transform="uppercase">CURRENT STATUS</text>
|
||||
<text x="35" y="115" fill="#22c55e" font-size="26" font-weight="700">UP</text>
|
||||
<rect x="20" y="65" width="140" height="4" rx="2" fill="#22c55e"/>
|
||||
|
||||
<!-- Card 2: Latency -->
|
||||
<rect x="175" y="65" width="140" height="80" rx="10" fill="#1e293b" stroke="#334155" stroke-width="1"/>
|
||||
<text x="190" y="85" fill="#64748b" font-size="9" font-weight="600">HTTP LATENCY</text>
|
||||
<text x="190" y="115" fill="#e2e8f0" font-size="26" font-weight="700">142</text>
|
||||
<text x="260" y="115" fill="#64748b" font-size="10">ms</text>
|
||||
|
||||
<!-- Card 3: DNS -->
|
||||
<rect x="330" y="65" width="140" height="80" rx="10" fill="#1e293b" stroke="#334155" stroke-width="1"/>
|
||||
<text x="345" y="85" fill="#64748b" font-size="9" font-weight="600">DNS LOOKUP</text>
|
||||
<text x="345" y="115" fill="#e2e8f0" font-size="26" font-weight="700">23</text>
|
||||
<text x="395" y="115" fill="#64748b" font-size="10">ms</text>
|
||||
|
||||
<!-- Card 4: SSL -->
|
||||
<rect x="485" y="65" width="140" height="80" rx="10" fill="#1e293b" stroke="#334155" stroke-width="1"/>
|
||||
<text x="500" y="85" fill="#64748b" font-size="9" font-weight="600">SSL REMAINING</text>
|
||||
<text x="500" y="115" fill="#22c55e" font-size="26" font-weight="700">47</text>
|
||||
<text x="550" y="115" fill="#64748b" font-size="10">days</text>
|
||||
|
||||
<!-- Card 5: Search -->
|
||||
<rect x="640" y="65" width="140" height="80" rx="10" fill="#1e293b" stroke="#334155" stroke-width="1"/>
|
||||
<text x="655" y="85" fill="#64748b" font-size="9" font-weight="600">SEARCH RESPONSE</text>
|
||||
<text x="655" y="115" fill="#e2e8f0" font-size="26" font-weight="700">312</text>
|
||||
<text x="720" y="115" fill="#64748b" font-size="10">ms</text>
|
||||
|
||||
<!-- Card 6: Uptime -->
|
||||
<rect x="795" y="65" width="90" height="80" rx="10" fill="#1e293b" stroke="#22c55e" stroke-width="1.5"/>
|
||||
<text x="805" y="85" fill="#64748b" font-size="8" font-weight="600">UPTIME 24H</text>
|
||||
<text x="805" y="115" fill="#22c55e" font-size="22" font-weight="700">99.8%</text>
|
||||
|
||||
<!-- Latency Chart -->
|
||||
<rect x="20" y="160" width="530" height="200" rx="10" fill="#1e293b" stroke="#334155" stroke-width="1"/>
|
||||
<text x="40" y="185" fill="#94a3b8" font-size="11" font-weight="600">Latency (Last Hour)</text>
|
||||
<!-- Chart grid lines -->
|
||||
<line x1="60" y1="200" x2="530" y2="200" stroke="#2a2d3a" stroke-width="0.5"/>
|
||||
<line x1="60" y1="230" x2="530" y2="230" stroke="#2a2d3a" stroke-width="0.5"/>
|
||||
<line x1="60" y1="260" x2="530" y2="260" stroke="#2a2d3a" stroke-width="0.5"/>
|
||||
<line x1="60" y1="290" x2="530" y2="290" stroke="#2a2d3a" stroke-width="0.5"/>
|
||||
<line x1="60" y1="320" x2="530" y2="320" stroke="#2a2d3a" stroke-width="0.5"/>
|
||||
<!-- Y-axis labels -->
|
||||
<text x="50" y="204" fill="#64748b" font-size="8" text-anchor="end">500</text>
|
||||
<text x="50" y="234" fill="#64748b" font-size="8" text-anchor="end">400</text>
|
||||
<text x="50" y="264" fill="#64748b" font-size="8" text-anchor="end">300</text>
|
||||
<text x="50" y="294" fill="#64748b" font-size="8" text-anchor="end">200</text>
|
||||
<text x="50" y="324" fill="#64748b" font-size="8" text-anchor="end">100</text>
|
||||
<!-- Line chart -->
|
||||
<polyline points="70,290 100,285 130,280 160,275 190,270 220,265 250,260 280,255 310,250 340,245 370,240 400,235 430,230 460,225 490,220 520,215" fill="none" stroke="#4361ee" stroke-width="2"/>
|
||||
<!-- Fill under line -->
|
||||
<polyline points="70,290 100,285 130,280 160,275 190,270 220,265 250,260 280,255 310,250 340,245 370,240 400,235 430,230 460,225 490,220 520,215 520,330 70,330" fill="rgba(67,97,238,0.1)"/>
|
||||
<!-- Data points -->
|
||||
<circle cx="70" cy="290" r="3" fill="#4361ee"/>
|
||||
<circle cx="190" cy="270" r="3" fill="#4361ee"/>
|
||||
<circle cx="310" cy="250" r="3" fill="#4361ee"/>
|
||||
<circle cx="430" cy="230" r="3" fill="#4361ee"/>
|
||||
<circle cx="520" cy="215" r="3" fill="#4361ee"/>
|
||||
<!-- X-axis labels -->
|
||||
<text x="70" y="348" fill="#64748b" font-size="8" text-anchor="middle">14:00</text>
|
||||
<text x="190" y="348" fill="#64748b" font-size="8" text-anchor="middle">14:15</text>
|
||||
<text x="310" y="348" fill="#64748b" font-size="8" text-anchor="middle">14:30</text>
|
||||
<text x="430" y="348" fill="#64748b" font-size="8" text-anchor="middle">14:45</text>
|
||||
<text x="520" y="348" fill="#64748b" font-size="8" text-anchor="middle">15:00</text>
|
||||
|
||||
<!-- Search Response Chart -->
|
||||
<rect x="565" y="160" width="320" height="200" rx="10" fill="#1e293b" stroke="#334155" stroke-width="1"/>
|
||||
<text x="585" y="185" fill="#94a3b8" font-size="11" font-weight="600">Search Response Time</text>
|
||||
<!-- Bar chart -->
|
||||
<rect x="590" y="260" width="25" height="70" rx="3" fill="rgba(34,197,94,0.6)"/>
|
||||
<rect x="625" y="240" width="25" height="90" rx="3" fill="rgba(34,197,94,0.6)"/>
|
||||
<rect x="660" y="250" width="25" height="80" rx="3" fill="rgba(34,197,94,0.6)"/>
|
||||
<rect x="695" y="230" width="25" height="100" rx="3" fill="rgba(34,197,94,0.6)"/>
|
||||
<rect x="730" y="245" width="25" height="85" rx="3" fill="rgba(34,197,94,0.6)"/>
|
||||
<rect x="765" y="255" width="25" height="75" rx="3" fill="rgba(34,197,94,0.6)"/>
|
||||
<rect x="800" y="235" width="25" height="95" rx="3" fill="rgba(34,197,94,0.6)"/>
|
||||
<rect x="835" y="248" width="25" height="82" rx="3" fill="rgba(34,197,94,0.6)"/>
|
||||
|
||||
<!-- Table Section -->
|
||||
<rect x="20" y="375" width="865" height="135" rx="10" fill="#1e293b" stroke="#334155" stroke-width="1"/>
|
||||
<text x="40" y="400" fill="#94a3b8" font-size="11" font-weight="600">Latest 10 Checks</text>
|
||||
|
||||
<!-- Table header -->
|
||||
<rect x="35" y="410" width="835" height="22" fill="#0f172a" rx="4"/>
|
||||
<text x="55" y="425" fill="#64748b" font-size="8" font-weight="600">TIMESTAMP</text>
|
||||
<text x="200" y="425" fill="#64748b" font-size="8" font-weight="600">HTTP</text>
|
||||
<text x="280" y="425" fill="#64748b" font-size="8" font-weight="600">LATENCY</text>
|
||||
<text x="370" y="425" fill="#64748b" font-size="8" font-weight="600">DNS</text>
|
||||
<text x="450" y="425" fill="#64748b" font-size="8" font-weight="600">SSL</text>
|
||||
<text x="540" y="425" fill="#64748b" font-size="8" font-weight="600">SEARCH</text>
|
||||
<text x="640" y="425" fill="#64748b" font-size="8" font-weight="600">STATUS</text>
|
||||
|
||||
<!-- Table row 1 -->
|
||||
<text x="55" y="445" fill="#94a3b8" font-size="8">2026-07-27 15:00:12</text>
|
||||
<text x="200" y="445" fill="#e2e8f0" font-size="8">200</text>
|
||||
<text x="280" y="445" fill="#e2e8f0" font-size="8">142ms</text>
|
||||
<text x="370" y="445" fill="#e2e8f0" font-size="8">23ms</text>
|
||||
<text x="450" y="445" fill="#22c55e" font-size="8">47d</text>
|
||||
<text x="540" y="445" fill="#e2e8f0" font-size="8">312ms</text>
|
||||
<rect x="640" y="435" width="38" height="14" rx="7" fill="#0d3329"/>
|
||||
<text x="659" y="445" text-anchor="middle" fill="#22c55e" font-size="7" font-weight="600">UP</text>
|
||||
|
||||
<!-- Table row 2 -->
|
||||
<text x="55" y="462" fill="#94a3b8" font-size="8">2026-07-27 14:59:12</text>
|
||||
<text x="200" y="462" fill="#e2e8f0" font-size="8">200</text>
|
||||
<text x="280" y="462" fill="#e2e8f0" font-size="8">138ms</text>
|
||||
<text x="370" y="462" fill="#e2e8f0" font-size="8">21ms</text>
|
||||
<text x="450" y="462" fill="#22c55e" font-size="8">47d</text>
|
||||
<text x="540" y="462" fill="#e2e8f0" font-size="8">298ms</text>
|
||||
<rect x="640" y="452" width="38" height="14" rx="7" fill="#0d3329"/>
|
||||
<text x="659" y="462" text-anchor="middle" fill="#22c55e" font-size="7" font-weight="600">UP</text>
|
||||
|
||||
<!-- Table row 3 -->
|
||||
<text x="55" y="479" fill="#94a3b8" font-size="8">2026-07-27 14:58:12</text>
|
||||
<text x="200" y="479" fill="#e2e8f0" font-size="8">200</text>
|
||||
<text x="280" y="479" fill="#e2e8f0" font-size="8">155ms</text>
|
||||
<text x="370" y="479" fill="#e2e8f0" font-size="8">25ms</text>
|
||||
<text x="450" y="479" fill="#22c55e" font-size="8">47d</text>
|
||||
<text x="540" y="479" fill="#e2e8f0" font-size="8">341ms</text>
|
||||
<rect x="640" y="469" width="38" height="14" rx="7" fill="#0d3329"/>
|
||||
<text x="659" y="479" text-anchor="middle" fill="#22c55e" font-size="7" font-weight="600">UP</text>
|
||||
</svg>
|
||||
|
بعد العرض: | الارتفاع: | الحجم: 8.8 KiB |
37
q5-mithal-monitor/screenshots/monitor-preview.svg
Normal file
37
q5-mithal-monitor/screenshots/monitor-preview.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 300" font-family="Consolas, Monaco, monospace">
|
||||
<defs>
|
||||
<linearGradient id="tbg" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1b26"/>
|
||||
<stop offset="100%" style="stop-color:#16161e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Terminal background -->
|
||||
<rect width="700" height="300" fill="url(#tbg)" rx="10"/>
|
||||
<rect x="0" y="0" width="700" height="30" fill="#24283b" rx="10"/>
|
||||
<rect x="0" y="15" width="700" height="15" fill="#24283b"/>
|
||||
|
||||
<!-- Terminal buttons -->
|
||||
<circle cx="20" cy="15" r="6" fill="#f7768e"/>
|
||||
<circle cx="40" cy="15" r="6" fill="#e0af68"/>
|
||||
<circle cx="60" cy="15" r="6" fill="#9ece6a"/>
|
||||
<text x="350" y="19" text-anchor="middle" fill="#565f89" font-size="10">monitor.py — Terminal</text>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<text x="15" y="55" fill="#9ece6a" font-size="11">$ python monitor.py --interval 60</text>
|
||||
|
||||
<text x="15" y="80" fill="#565f89" font-size="10">============================================================</text>
|
||||
<text x="15" y="95" fill="#7aa2f7" font-size="10">Mithal.space Monitor started</text>
|
||||
<text x="15" y="110" fill="#94a3b8" font-size="10">Target URL : https://mithal.space</text>
|
||||
<text x="15" y="125" fill="#94a3b8" font-size="10">Check interval : 60 seconds</text>
|
||||
<text x="15" y="140" fill="#94a3b8" font-size="10">Max records : 1440</text>
|
||||
<text x="15" y="155" fill="#94a3b8" font-size="10">Metrics file : metrics.json</text>
|
||||
<text x="15" y="170" fill="#565f89" font-size="10">============================================================</text>
|
||||
|
||||
<text x="15" y="195" fill="#9ece6a" font-size="10">✓ status=200 latency=142ms dns=23ms ssl_days=47 search=312ms</text>
|
||||
<text x="15" y="215" fill="#9ece6a" font-size="10">✓ status=200 latency=138ms dns=21ms ssl_days=47 search=298ms</text>
|
||||
<text x="15" y="235" fill="#9ece6a" font-size="10">✓ status=200 latency=155ms dns=25ms ssl_days=47 search=341ms</text>
|
||||
|
||||
<text x="15" y="260" fill="#bb9af7" font-size="10">[2026-07-27 15:00:12] INFO ✓ status=200 latency=142ms</text>
|
||||
<text x="15" y="278" fill="#73daca" font-size="10">█</text>
|
||||
</svg>
|
||||
|
بعد العرض: | الارتفاع: | الحجم: 2.2 KiB |
423
q5-mithal-monitor/script.js
Normal file
423
q5-mithal-monitor/script.js
Normal file
@@ -0,0 +1,423 @@
|
||||
/* =========================================================================
|
||||
Mithal.space Monitor — Dashboard JavaScript
|
||||
========================================================================= */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Configuration
|
||||
// -----------------------------------------------------------------------
|
||||
const METRICS_URL = "metrics.json";
|
||||
const REFRESH_INTERVAL = 30; // seconds
|
||||
const CHART_MAX_POINTS = 60; // last hour (60 × 60s)
|
||||
const LAST_HOUR_CHECKS = 60;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State
|
||||
// -----------------------------------------------------------------------
|
||||
let countdownValue = REFRESH_INTERVAL;
|
||||
let countdownTimer = null;
|
||||
let metricsData = [];
|
||||
let latencyChart = null;
|
||||
let searchChart = null;
|
||||
let dnsChart = null;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DOM References
|
||||
// -----------------------------------------------------------------------
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
const els = {
|
||||
loading: $("#loadingIndicator"),
|
||||
currentStatus: $("#currentStatus"),
|
||||
statusIndicator: $("#statusIndicator"),
|
||||
currentLatency: $("#currentLatency"),
|
||||
currentDns: $("#currentDns"),
|
||||
currentSsl: $("#currentSsl"),
|
||||
currentSearch: $("#currentSearch"),
|
||||
uptimePercent: $("#uptimePercent"),
|
||||
countdown: $("#countdown"),
|
||||
refreshBtn: $("#refreshBtn"),
|
||||
themeToggle: $("#themeToggle"),
|
||||
exportCsv: $("#exportCsv"),
|
||||
metricsBody: $("#metricsBody"),
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Theme
|
||||
// -----------------------------------------------------------------------
|
||||
function loadTheme() {
|
||||
const saved = localStorage.getItem("mithal-theme");
|
||||
if (saved) {
|
||||
document.documentElement.setAttribute("data-theme", saved);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute("data-theme");
|
||||
const next = current === "dark" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
localStorage.setItem("mithal-theme", next);
|
||||
updateChartColors();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Chart Colour Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
function getThemeColors() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
line: style.getPropertyValue("--chart-line").trim(),
|
||||
fill: style.getPropertyValue("--chart-fill").trim(),
|
||||
text: style.getPropertyValue("--text-secondary").trim(),
|
||||
grid: style.getPropertyValue("--border-color").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function updateChartColors() {
|
||||
const c = getThemeColors();
|
||||
[latencyChart, searchChart, dnsChart].forEach((chart) => {
|
||||
if (!chart) return;
|
||||
chart.options.scales.x.ticks.color = c.text;
|
||||
chart.options.scales.y.ticks.color = c.text;
|
||||
chart.options.scales.x.grid.color = c.grid;
|
||||
chart.options.scales.y.grid.color = c.grid;
|
||||
if (chart.data.datasets[0]) {
|
||||
chart.data.datasets[0].borderColor = c.line;
|
||||
chart.data.datasets[0].backgroundColor = c.fill;
|
||||
}
|
||||
chart.update("none");
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fetch Metrics
|
||||
// -----------------------------------------------------------------------
|
||||
async function fetchMetrics() {
|
||||
try {
|
||||
els.loading.classList.add("active");
|
||||
const resp = await fetch(METRICS_URL + "?t=" + Date.now());
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
metricsData = await resp.json();
|
||||
} catch (err) {
|
||||
console.warn("Failed to load metrics:", err);
|
||||
metricsData = [];
|
||||
} finally {
|
||||
els.loading.classList.remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Update Cards
|
||||
// -----------------------------------------------------------------------
|
||||
function updateCards() {
|
||||
if (metricsData.length === 0) {
|
||||
els.currentStatus.textContent = "--";
|
||||
els.currentLatency.textContent = "--";
|
||||
els.currentDns.textContent = "--";
|
||||
els.currentSsl.textContent = "--";
|
||||
els.currentSearch.textContent = "--";
|
||||
els.uptimePercent.textContent = "--";
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = metricsData[metricsData.length - 1];
|
||||
|
||||
// Status
|
||||
if (latest.uptime) {
|
||||
els.currentStatus.textContent = "UP";
|
||||
els.currentStatus.className = "card-value status-up";
|
||||
els.statusIndicator.style.background = "var(--green)";
|
||||
els.currentStatus.classList.add("card-status-pulse");
|
||||
} else {
|
||||
els.currentStatus.textContent = "DOWN";
|
||||
els.currentStatus.className = "card-value status-down";
|
||||
els.statusIndicator.style.background = "var(--red)";
|
||||
els.currentStatus.classList.remove("card-status-pulse");
|
||||
}
|
||||
|
||||
// Values
|
||||
els.currentLatency.textContent = latest.latency_ms != null ? Math.round(latest.latency_ms) : "--";
|
||||
els.currentDns.textContent = latest.dns_ms != null ? Math.round(latest.dns_ms) : "--";
|
||||
els.currentSearch.textContent = latest.search_ms != null ? Math.round(latest.search_ms) : "--";
|
||||
|
||||
// SSL
|
||||
if (latest.ssl && latest.ssl.days_remaining != null) {
|
||||
const days = latest.ssl.days_remaining;
|
||||
els.currentSsl.textContent = days;
|
||||
if (days <= 14) {
|
||||
els.currentSsl.className = "card-value status-down";
|
||||
} else if (days <= 30) {
|
||||
els.currentSsl.className = "card-value status-warn";
|
||||
} else {
|
||||
els.currentSsl.className = "card-value";
|
||||
}
|
||||
} else {
|
||||
els.currentSsl.textContent = "--";
|
||||
els.currentSsl.className = "card-value";
|
||||
}
|
||||
|
||||
// Uptime %
|
||||
const upChecks = metricsData.filter((m) => m.uptime).length;
|
||||
const uptime = ((upChecks / metricsData.length) * 100).toFixed(2);
|
||||
els.uptimePercent.textContent = uptime;
|
||||
if (parseFloat(uptime) >= 99.5) {
|
||||
els.uptimePercent.className = "card-value status-up";
|
||||
} else if (parseFloat(uptime) >= 95) {
|
||||
els.uptimePercent.className = "card-value status-warn";
|
||||
} else {
|
||||
els.uptimePercent.className = "card-value status-down";
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Update Charts
|
||||
// -----------------------------------------------------------------------
|
||||
function updateCharts() {
|
||||
const c = getThemeColors();
|
||||
|
||||
// Last hour of data (approx 60 checks at 60s interval)
|
||||
const lastHour = metricsData.slice(-LAST_HOUR_CHECKS);
|
||||
|
||||
const labels = lastHour.map((m) => {
|
||||
const d = new Date(m.timestamp);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
});
|
||||
|
||||
const latencyData = lastHour.map((m) => (m.latency_ms != null ? Math.round(m.latency_ms) : null));
|
||||
const searchData = lastHour.map((m) => (m.search_ms != null ? Math.round(m.search_ms) : null));
|
||||
const dnsData = lastHour.map((m) => (m.dns_ms != null ? Math.round(m.dns_ms) : null));
|
||||
|
||||
// --- Latency Chart ---
|
||||
if (latencyChart) latencyChart.destroy();
|
||||
latencyChart = new Chart($("#latencyChart"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Latency (ms)",
|
||||
data: latencyData,
|
||||
borderColor: c.line,
|
||||
backgroundColor: c.fill,
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 2,
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: chartOptions(c, "ms"),
|
||||
});
|
||||
|
||||
// --- Search Chart ---
|
||||
if (searchChart) searchChart.destroy();
|
||||
searchChart = new Chart($("#searchChart"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Search (ms)",
|
||||
data: searchData,
|
||||
borderColor: "#22c55e",
|
||||
backgroundColor: "rgba(34,197,94,0.1)",
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 2,
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: chartOptions(c, "ms"),
|
||||
});
|
||||
|
||||
// --- DNS Chart ---
|
||||
if (dnsChart) dnsChart.destroy();
|
||||
dnsChart = new Chart($("#dnsChart"), {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "DNS (ms)",
|
||||
data: dnsData,
|
||||
backgroundColor: "rgba(234,179,8,0.6)",
|
||||
borderColor: "#eab308",
|
||||
borderWidth: 1,
|
||||
borderRadius: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: chartOptions(c, "ms"),
|
||||
});
|
||||
}
|
||||
|
||||
function chartOptions(c, unit) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0,0,0,0.8)",
|
||||
titleColor: "#fff",
|
||||
bodyColor: "#fff",
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
callbacks: {
|
||||
label: function (ctx) {
|
||||
return ctx.parsed.y != null ? ctx.parsed.y + " " + unit : "N/A";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: c.text, maxTicksLimit: 8, font: { size: 11 } },
|
||||
grid: { color: c.grid },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: c.text, font: { size: 11 } },
|
||||
grid: { color: c.grid },
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Update Table
|
||||
// -----------------------------------------------------------------------
|
||||
function updateTable() {
|
||||
const last10 = metricsData.slice(-10).reverse();
|
||||
|
||||
if (last10.length === 0) {
|
||||
els.metricsBody.innerHTML =
|
||||
'<tr><td colspan="7" class="no-data">No data available</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
els.metricsBody.innerHTML = last10
|
||||
.map((m) => {
|
||||
const ts = new Date(m.timestamp).toLocaleString();
|
||||
const sslDays = m.ssl && m.ssl.days_remaining != null ? m.ssl.days_remaining : "--";
|
||||
const searchMs = m.search_ms != null ? Math.round(m.search_ms) : "--";
|
||||
|
||||
let statusClass = "status-down";
|
||||
let statusText = "DOWN";
|
||||
if (m.uptime) {
|
||||
if (m.status_code >= 300) {
|
||||
statusClass = "status-warn";
|
||||
statusText = "WARN";
|
||||
} else {
|
||||
statusClass = "status-up";
|
||||
statusText = "UP";
|
||||
}
|
||||
}
|
||||
|
||||
return `<tr>
|
||||
<td>${ts}</td>
|
||||
<td>${m.status_code || "--"}</td>
|
||||
<td>${m.latency_ms != null ? Math.round(m.latency_ms) : "--"}</td>
|
||||
<td>${m.dns_ms != null ? Math.round(m.dns_ms) : "--"}</td>
|
||||
<td>${sslDays}</td>
|
||||
<td>${searchMs}</td>
|
||||
<td><span class="status-badge ${statusClass}">${statusText}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Countdown & Auto-Refresh
|
||||
// -----------------------------------------------------------------------
|
||||
function resetCountdown() {
|
||||
countdownValue = REFRESH_INTERVAL;
|
||||
els.countdown.textContent = countdownValue + "s";
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await fetchMetrics();
|
||||
updateCards();
|
||||
updateCharts();
|
||||
updateTable();
|
||||
resetCountdown();
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
countdownTimer = setInterval(() => {
|
||||
countdownValue--;
|
||||
if (countdownValue <= 0) {
|
||||
refreshAll();
|
||||
} else {
|
||||
els.countdown.textContent = countdownValue + "s";
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CSV Export
|
||||
// -----------------------------------------------------------------------
|
||||
function exportCsv() {
|
||||
if (metricsData.length === 0) return;
|
||||
|
||||
const headers = [
|
||||
"Timestamp",
|
||||
"Status Code",
|
||||
"Uptime",
|
||||
"Latency (ms)",
|
||||
"DNS (ms)",
|
||||
"SSL Valid",
|
||||
"SSL Expires",
|
||||
"SSL Days Remaining",
|
||||
"Search (ms)",
|
||||
];
|
||||
|
||||
const rows = metricsData.map((m) => [
|
||||
m.timestamp,
|
||||
m.status_code,
|
||||
m.uptime,
|
||||
m.latency_ms,
|
||||
m.dns_ms,
|
||||
m.ssl ? m.ssl.valid : "",
|
||||
m.ssl ? m.ssl.expires : "",
|
||||
m.ssl ? m.ssl.days_remaining : "",
|
||||
m.search_ms,
|
||||
]);
|
||||
|
||||
const csvContent = [headers, ...rows].map((r) => r.join(",")).join("\n");
|
||||
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "mithal_monitor_" + new Date().toISOString().slice(0, 10) + ".csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Init
|
||||
// -----------------------------------------------------------------------
|
||||
function init() {
|
||||
loadTheme();
|
||||
refreshAll();
|
||||
startCountdown();
|
||||
|
||||
els.refreshBtn.addEventListener("click", () => refreshAll());
|
||||
els.themeToggle.addEventListener("click", toggleTheme);
|
||||
els.exportCsv.addEventListener("click", exportCsv);
|
||||
}
|
||||
|
||||
// Start when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
428
q5-mithal-monitor/style.css
Normal file
428
q5-mithal-monitor/style.css
Normal file
@@ -0,0 +1,428 @@
|
||||
/* =========================================================================
|
||||
Mithal.space Monitor — Dashboard Stylesheet
|
||||
========================================================================= */
|
||||
|
||||
/* ---------- Reset & Base ---------- */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Light theme */
|
||||
--bg-primary: #f0f2f5;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: #555770;
|
||||
--text-muted: #8e8ea0;
|
||||
--border-color: #e0e0e6;
|
||||
--accent: #4361ee;
|
||||
--accent-light: #e8ecff;
|
||||
--green: #22c55e;
|
||||
--green-bg: #dcfce7;
|
||||
--yellow: #eab308;
|
||||
--yellow-bg: #fef9c3;
|
||||
--red: #ef4444;
|
||||
--red-bg: #fee2e2;
|
||||
--chart-line: #4361ee;
|
||||
--chart-fill: rgba(67, 97, 238, 0.12);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--transition: 0.25s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #0f1117;
|
||||
--bg-secondary: #181b24;
|
||||
--bg-card: #1e2130;
|
||||
--text-primary: #e4e4ed;
|
||||
--text-secondary: #a0a0b8;
|
||||
--text-muted: #6b6b80;
|
||||
--border-color: #2a2d3a;
|
||||
--accent: #6c8cff;
|
||||
--accent-light: #1e2740;
|
||||
--green: #34d399;
|
||||
--green-bg: #0d3329;
|
||||
--yellow: #facc15;
|
||||
--yellow-bg: #3b3508;
|
||||
--red: #f87171;
|
||||
--red-bg: #3b1212;
|
||||
--chart-line: #6c8cff;
|
||||
--chart-fill: rgba(108, 140, 255, 0.10);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
/* ---------- Dashboard Container ---------- */
|
||||
.dashboard {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 18px 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 24px;
|
||||
transition: background var(--transition), border var(--transition);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 1.15rem;
|
||||
color: var(--text-primary);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.icon-moon { display: none; }
|
||||
[data-theme="dark"] .icon-sun { display: none; }
|
||||
[data-theme="dark"] .icon-moon { display: inline; }
|
||||
|
||||
/* Refresh Info */
|
||||
.refresh-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Export Button */
|
||||
.export-btn {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
/* ---------- Loading Indicator ---------- */
|
||||
.loading-indicator {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.loading-indicator.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---------- Status Cards ---------- */
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(185px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: background var(--transition), border var(--transition),
|
||||
box-shadow var(--transition);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 1.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.card-unit {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.card-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
/* Status animation */
|
||||
.card-status-pulse {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* ---------- Charts ---------- */
|
||||
.charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: background var(--transition), border var(--transition);
|
||||
}
|
||||
|
||||
.chart-container h3 {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ---------- Table ---------- */
|
||||
.table-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: background var(--transition), border var(--transition);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-section h3 {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 24px 0 !important;
|
||||
}
|
||||
|
||||
/* Status badge */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-up {
|
||||
background: var(--green-bg);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.status-down {
|
||||
background: var(--red-bg);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
background: var(--yellow-bg);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
thead th, tbody td {
|
||||
padding: 8px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم