commit a6e65f3f3763bc3ed5500c8793c69d08f15301ac Author: amirkasseb Date: Sun Jul 26 19:57:48 2026 +0300 first commit diff --git a/common-mortakaz/integration-1.md b/common-mortakaz/integration-1.md new file mode 100644 index 0000000..8b381e0 --- /dev/null +++ b/common-mortakaz/integration-1.md @@ -0,0 +1,79 @@ +# Integration Proposal 1: قُمرة × Ghaymah + +## 1. وصف المنتج + +قُمرة هي منصة متخصصة في إنشاء المتاجر والمواقع الإلكترونية، وتهدف إلى تسهيل عملية بناء حضور رقمي للأفراد وأصحاب المشاريع دون الحاجة إلى إدارة البنية التحتية التقنية بشكل معقد. + +يمكن أن تستفيد منصة قُمرة من التكامل مع خدمات غيمة لتوفير بنية تحتية سحابية عربية تساعد على تشغيل المواقع والمتاجر المستضافة عليها بشكل أكثر موثوقية وقابلية للتوسع. + +## 2. اقتراح التكامل + +أقترح ربط قُمرة مع خدمات غيمة بحيث يتم تشغيل المواقع والمتاجر المنشأة عبر المنصة على بنية تحتية سحابية قابلة للتوسع. + +يمكن استخدام ghaymah.systems كطبقة للبنية التحتية والاستضافة، بينما يمكن استخدام mithal.space كمثال على خدمة مراقبة مستقلة لمتابعة حالة المواقع المستضافة. + +التدفق المقترح: + +```text + User / Customer + | + v + [ Qumra Store ] + | + v + [ Ghaymah Cloud ] + | + +---------+---------+ + | | + v v + Application Server Database/Storage + | + v + [ mithal.space ] + Monitoring + | + +-----+-----+ + | | + v v + Uptime Latency + SSL Errors +``` + +تقوم قُمرة بإنشاء موقع أو متجر للعميل، ثم يتم تشغيل التطبيق على موارد غيمة. تقوم خدمة المراقبة بمتابعة توفر الموقع وزمن الاستجابة وحالة SSL، مما يسمح باكتشاف المشاكل مبكراً. + +## 3. القيمة المضافة للمستخدم النهائي + +هذا التكامل يمكن أن يوفر عدة فوائد: + +* تحسين موثوقية المواقع والمتاجر الإلكترونية. +* توفير بنية تحتية قابلة للتوسع عند زيادة عدد الزوار. +* تقليل الحاجة إلى إدارة الخوادم بشكل مباشر من قبل صاحب المتجر. +* مراقبة حالة الموقع بشكل مستمر. +* اكتشاف الأعطال ومشاكل SSL والأداء بشكل مبكر. +* توفير تجربة أفضل للمستخدم النهائي أثناء تصفح المتجر وإتمام عمليات الشراء. +* إمكانية بناء خدمة استضافة عربية متكاملة تجمع بين إنشاء الموقع والاستضافة والمراقبة. + +## 4. التحديات التقنية والتجارية المحتملة + +### التحديات التقنية + +* تصميم آلية آمنة لعزل مواقع العملاء عن بعضها. +* إدارة الموارد عند وجود عدد كبير من المتاجر. +* التعامل مع الزيادات المفاجئة في حركة المرور. +* توفير النسخ الاحتياطي واستعادة البيانات. +* مراقبة الأداء على مستوى كل متجر. +* ضمان أمن البيانات وبيانات العملاء. + +### التحديات التجارية + +* تكلفة تشغيل البنية التحتية لكل متجر. +* تحديد نموذج التسعير المناسب بين قُمرة وغيمة. +* انتقال العملاء الحاليين إلى البنية الجديدة. +* الحاجة إلى اتفاق واضح حول مسؤولية الدعم الفني. +* ضمان مستوى توفر مناسب للمتاجر التجارية. + +## 5. قابلية التطبيق + +أرى أن التكامل قابل للتطبيق بشكل جيد، خصوصاً إذا بدأ كتجربة محدودة على مجموعة من المتاجر، ثم يتم التوسع تدريجياً بناءً على الأداء والتكلفة. + +يمكن البدء بتشغيل المواقع الجديدة على بنية غيمة، مع تفعيل المراقبة عبر mithal.space، ثم إضافة ميزات مثل النسخ الاحتياطي والتوسع التلقائي في مراحل لاحقة. diff --git a/common-mortakaz/integration-2.md b/common-mortakaz/integration-2.md new file mode 100644 index 0000000..784cb23 --- /dev/null +++ b/common-mortakaz/integration-2.md @@ -0,0 +1,97 @@ +# Integration Proposal 2: DeployBase × Ghaymah + +## 1. وصف المنتج + +DeployBase هي منصة تهدف إلى تسهيل عملية نشر التطبيقات للمطورين، بحيث يستطيع المطور إطلاق تطبيقه بسرعة وبطريقة أبسط من إدارة البنية التحتية والنشر يدوياً. + +هذا النوع من المنتجات مناسب بشكل مباشر للتكامل مع مزود خدمات سحابية، لأن عملية النشر تحتاج إلى موارد حوسبة وشبكات وتخزين ومراقبة. + +## 2. اقتراح التكامل + +أقترح إضافة غيمة كمزود بنية تحتية سحابية داخل DeployBase. + +يستطيع المطور اختيار Ghaymah كوجهة للنشر من داخل DeployBase، ثم تقوم المنصة بأتمتة عملية إنشاء الموارد المطلوبة وتشغيل التطبيق عليها. + +التدفق المقترح: + +```text + Developer + | + v + Git Repository + | + v + [ DeployBase ] + | + | Deploy + v +[ Ghaymah Systems ] + | + +---+----------------+ + | | + v v +Compute Storage + | | + +---------+----------+ + | + v + Running App + | + v + [ mithal.space ] + Monitoring + | + +----+----+ + | | + v v + Uptime Latency + SSL Errors +``` + +يقوم المطور بربط مستودع المشروع مع DeployBase. عند تنفيذ عملية Deploy، تقوم DeployBase بتشغيل التطبيق على البنية التحتية الخاصة بغيمة. + +بعد تشغيل التطبيق، يمكن استخدام mithal.space لمراقبة الخدمة وقياس زمن الاستجابة والتوفر وحالة شهادة SSL. + +## 3. القيمة المضافة للمستخدم النهائي + +التكامل يوفر للمطور تجربة نشر متكاملة: + +* نشر التطبيقات بشكل أسرع. +* تقليل التعقيد المرتبط بإدارة الخوادم. +* توفير خيار استضافة سحابي عربي. +* تقليل عدد الأدوات التي يحتاج المطور إلى التعامل معها. +* إمكانية مراقبة التطبيق بعد النشر. +* اكتشاف الأعطال ومشاكل الأداء بسرعة. +* إنشاء تجربة DevOps مبسطة للمطورين والشركات الناشئة. + +كما يمكن أن يوفر التكامل فرصة للمطور لتطوير التطبيق وإدارته ونشره ومراقبته من خلال سلسلة عمل واحدة. + +## 4. التحديات التقنية والتجارية المحتملة + +### التحديات التقنية + +* توفير API موثقة ومستقرة بين DeployBase وغيمة. +* أتمتة إنشاء وإدارة الموارد السحابية. +* إدارة أسرار وبيانات الاعتماد بشكل آمن. +* التعامل مع اختلاف أنواع التطبيقات ومتطلباتها. +* توفير Logging وMonitoring مناسبين. +* التعامل مع عمليات النشر الفاشلة وإعادة النشر. +* إدارة التوسع والموارد عند ارتفاع الاستخدام. + +### التحديات التجارية + +* الاتفاق على نموذج الشراكة بين المنصتين. +* تحديد تكلفة استخدام البنية التحتية. +* منافسة مزودي الخدمات السحابية العالميين. +* بناء ثقة المطورين في الخدمة الجديدة. +* توفير دعم فني كافٍ للمستخدمين. + +## 5. قابلية التطبيق + +أرى أن التكامل يمكن تنفيذه تدريجياً، بدءاً بدعم نوع محدد من التطبيقات، مثل تطبيقات Docker، ثم إضافة المزيد من أنواع التطبيقات والخدمات لاحقاً. + +يمكن أن يبدأ التكامل من خلال API بسيطة تسمح لـ DeployBase بإنشاء موارد وتشغيل التطبيقات على غيمة، ثم تطويرها إلى منصة نشر متكاملة تشمل CI/CD والمراقبة والتوسع. + +## 6. النتيجة + +هذا التكامل يحول DeployBase من منصة تركز على عملية النشر فقط إلى تجربة أكثر تكاملاً تشمل النشر والبنية التحتية والمراقبة، بينما تستفيد غيمة من الوصول إلى شريحة واسعة من المطورين والشركات الناشئة. diff --git a/common-qabilah/qabilah-profile.txt b/common-qabilah/qabilah-profile.txt new file mode 100644 index 0000000..fb8f933 --- /dev/null +++ b/common-qabilah/qabilah-profile.txt @@ -0,0 +1 @@ +https://qabilah.com/profile/amir-m-kasseb/professional-profile \ No newline at end of file diff --git a/q1-deploy-monitor/README.md b/q1-deploy-monitor/README.md new file mode 100644 index 0000000..c7b8041 --- /dev/null +++ b/q1-deploy-monitor/README.md @@ -0,0 +1,137 @@ +# Ghaymah Cloud Container Deployment & Monitoring + +A lightweight, production-ready Go REST API deployed on **ghaymah.systems** containers platform, featuring an automated Bash health monitoring daemon and a modern web-based SRE monitoring dashboard. + +--- + +## 📌 Project Overview + +This repository fulfills the SRE Container Deployment and Monitoring technical requirements: + +1. **REST API Service (`app/main.go`)**: Built in Go, offering `/`, `/health`, and `/metrics` endpoints with CORS enabled. +2. **Containerization (`app/Dockerfile`)**: Optimized multi-stage Docker build utilizing Alpine Linux and running under a non-root security context. +3. **Cloud Deployment**: Deployed on `ghaymah.systems` container platform at: + - **Live Health Endpoint**: `https://gheyma-app-2b88268529f9.hosted.ghaymah.systems/health` + - **Live Metrics Endpoint**: `https://gheyma-app-2b88268529f9.hosted.ghaymah.systems/metrics` +4. **Automated Monitoring Daemon (`health-check.sh`)**: Bash script executing periodic health probes every 30 seconds, measuring response time, HTTP status, and logging results to `monitor.log`. +5. **SRE Live Dashboard (`dashboard/`)**: Single-page frontend dashboard built with HTML, CSS, and Vanilla JavaScript displaying live service health status, real-time latency graphs, total API request counts, and uptime timeline. + +--- + +## 🏗️ Architecture & Directory Structure + +``` +. +├── app/ # Application backend files +│ ├── main.go # Go API server source code +│ ├── go.mod # Go module specification +│ ├── Dockerfile # Multi-stage Dockerfile for Go API +│ └── .dockerignore # Docker ignore rules +├── dashboard/ # Monitoring Web Dashboard +│ ├── Dockerfile # Nginx Dockerfile for static frontend serving +│ ├── index.html # Main dashboard UI structure +│ ├── style.css # Custom styling (dark mode, glassmorphism, animations) +│ └── script.js # Frontend engine (live polling, canvas rendering, state tracking) +├── health-check.sh # Bash health check monitoring script (every 30s) +├── monitor.log # Output log generated by monitoring script +└── README.md # Project documentation +``` + +### 1. Go API Server (`app/main.go`) +- **Endpoints**: + - `GET /`: Returns welcome payload `{"message": "Hello, World!"}` and increments request counter. + - `GET /health`: Returns service health status `{"status": "healthy"}` and increments request counter. + - `GET /metrics`: Exposes internal request metrics `{"requests": }` without incrementing counter. +- **CORS Support**: Implements `Access-Control-Allow-Origin: *` to enable browser-based dashboard monitoring across domains. + +### 2. Multi-Stage Dockerfile (`app/Dockerfile`) +- **Build Stage**: Compiles the Go application binary using `golang:1.26-alpine`. +- **Runtime Stage**: Executes inside lightweight `alpine:3.20`. +- **Security Context**: Creates and executes under an unprivileged `appuser` (non-root). +- **Port Exposure**: Exposes port `8080`. + +### 3. Monitoring Script (`health-check.sh`) +- **Probe Frequency**: Every 30 seconds (`INTERVAL=30`). +- **Target URL**: `https://gheyma-app-2b88268529f9.hosted.ghaymah.systems/health`. +- **Metrics Collected**: HTTP Status Code and total response execution time (seconds). +- **Log Format**: `YYYY-MM-DD HH:MM:SS - STATUS_LEVEL - Details` appended to `monitor.log`. + +### 4. Live SRE Dashboard (`dashboard/`) +- **KPI Metrics**: + - **Service Status**: Visual indicator (Healthy / Unhealthy). + - **Response Time**: Real-time round-trip latency in milliseconds + history canvas chart. + - **Total Requests**: Fetched live from `/metrics`. +- **Features**: + - Auto-refresh interval (15s) with manual refresh option. + - Success ratio doughnut chart and interactive check timeline. + - Primary cloud endpoint monitoring with local fallback capability. + +--- + +## 🚀 Getting Started & Local Usage + +### Running the API Server Locally + +```bash +# Navigate to app directory and run Go service +cd app +go run main.go +``` +The server will start listening on `http://localhost:8080`. + +### Building & Running with Docker + +```bash +# Build the Docker image from root directory +docker build -t ghaymah-api-service ./app + +# Run the container +docker run -d -p 8080:8080 --name ghaymah-api ghaymah-api-service +``` + +### Running the Monitoring Script + +```bash +# Grant execution permissions (Linux/macOS/Git Bash) +chmod +x health-check.sh + +# Run the monitoring daemon +./health-check.sh +``` + +### Viewing the Monitoring Dashboard + +You can serve the `dashboard/` directory using Nginx, Docker, or any static file server: + +```bash +# Using Docker (Nginx) +cd dashboard +docker build -t ghaymah-dashboard . +docker run -d -p 80:80 ghaymah-dashboard +``` + +Or open `dashboard/index.html` directly in your web browser. + +--- + +## 🔍 Verification & Health Checks + +Verify the live cloud application using `curl`: + +```bash +# Health endpoint check +curl -i https://gheyma-app-2b88268529f9.hosted.ghaymah.systems/health + +# Expected output: +# HTTP/1.1 200 OK +# Content-Type: application/json +# {"status":"healthy"} + +# Metrics endpoint check +curl -i https://gheyma-app-2b88268529f9.hosted.ghaymah.systems/metrics + +# Expected output: +# HTTP/1.1 200 OK +# Content-Type: application/json +# {"requests":53} +``` diff --git a/q1-deploy-monitor/app/.dockerignore b/q1-deploy-monitor/app/.dockerignore new file mode 100644 index 0000000..9717bf6 --- /dev/null +++ b/q1-deploy-monitor/app/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +.env +.env.* +README.md +Dockerfile +.dockerignore +dashboard \ No newline at end of file diff --git a/q1-deploy-monitor/app/Dockerfile b/q1-deploy-monitor/app/Dockerfile new file mode 100644 index 0000000..5cde482 --- /dev/null +++ b/q1-deploy-monitor/app/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM golang:1.26-alpine AS builder + +WORKDIR /app + +# Copy dependency files first to leverage Docker cache +COPY go.mod ./ + +# Copy source code +COPY . . + +# Build the application +RUN go build -o server . + + +# Runtime stage +FROM alpine:3.20 + +# Create a non-root user +RUN adduser -D appuser + +WORKDIR /app + +# Copy the compiled binary +COPY --from=builder /app/server . + +# Run as non-root user +USER appuser + +# Document the application port +EXPOSE 8080 + +# Start the application +CMD ["./server"] \ No newline at end of file diff --git a/q1-deploy-monitor/app/go.mod b/q1-deploy-monitor/app/go.mod new file mode 100644 index 0000000..cc97c0c --- /dev/null +++ b/q1-deploy-monitor/app/go.mod @@ -0,0 +1,3 @@ +module q1-deploy-monitor + +go 1.26.5 diff --git a/q1-deploy-monitor/app/main.go b/q1-deploy-monitor/app/main.go new file mode 100644 index 0000000..43f1d39 --- /dev/null +++ b/q1-deploy-monitor/app/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync/atomic" +) + +// Stores the total number of API requests. +var requestCount uint64 + +func main() { + // Register API endpoints + http.HandleFunc("/", requestCounter(helloHandler)) + http.HandleFunc("/health", requestCounter(healthHandler)) + http.HandleFunc("/metrics", metricsHandler) + + // Start server on port 8080 + fmt.Println("Server is running on port 8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +// Middleware that counts API requests. +// The /metrics endpoint is not wrapped with this middleware, +// so requests to /metrics are not included in the counter. +func requestCounter(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + atomic.AddUint64(&requestCount, 1) + next(w, r) + } +} + +// Adds CORS headers to allow the dashboard to access the API. +func enableCORS(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") +} + +// Root endpoint +func helloHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + + // Handle browser CORS preflight requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + json.NewEncoder(w).Encode(map[string]string{ + "message": "Hello, World!", + }) +} + +// Health check endpoint +func healthHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + + // Handle browser CORS preflight requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + json.NewEncoder(w).Encode(map[string]string{ + "status": "healthy", + }) +} + +// Metrics endpoint +// This endpoint does NOT increment the request counter. +func metricsHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + + // Handle browser CORS preflight requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + json.NewEncoder(w).Encode(map[string]uint64{ + "requests": atomic.LoadUint64(&requestCount), + }) +} diff --git a/q1-deploy-monitor/dashboard/Dockerfile b/q1-deploy-monitor/dashboard/Dockerfile new file mode 100644 index 0000000..4d01d1d --- /dev/null +++ b/q1-deploy-monitor/dashboard/Dockerfile @@ -0,0 +1,9 @@ +FROM nginx:alpine + +# Copy frontend files to Nginx's web root +COPY index.html /usr/share/nginx/html/ +COPY style.css /usr/share/nginx/html/ +COPY script.js /usr/share/nginx/html/ + +# Expose HTTP port +EXPOSE 80 \ No newline at end of file diff --git a/q1-deploy-monitor/dashboard/index.html b/q1-deploy-monitor/dashboard/index.html new file mode 100644 index 0000000..f4889dc --- /dev/null +++ b/q1-deploy-monitor/dashboard/index.html @@ -0,0 +1,240 @@ + + + + + +go-web-service · Cloud Monitoring + + + + + + + + + + +
+ + +
+
+ + +
+

go-web-service

+

/health + /metrics · hosted.ghaymah.systems

+
+
+ +
+
+ last updated + +
+ +
+
+ +
+ + +
+ +
+
+ Service Status + + + +
+
+ + Checking… +
+
awaiting first check
+
+ +
+
+ Response Time + + + +
+
+
current
+
+ +
+
+ Total Requests + + + +
+
+
reported by /metrics
+
+ +
+ + + + +
+
+
+

Response Time

+ last 30 live checks (ms) +
+
+ +
+
+ +
+
+

Check Results

+ each block = one live check +
+
+
+ + + 0 passed + + + + 0 failed + +
+ +
+ no data yet + now +
+
+
+
+ + +
+
+
+

Uptime Window

+ 0 live checks recorded +
+
+ + availability +
+ +
+ no data yet + now +
+
+ +
+
+

Success Ratio

+ session checks +
+
+ +
+ + success +
+
+
+
+ Passed + + +
+
+ Failed + + +
+
+
+
+ + +
+
+
+

Latest Health Check

+
+
+
+
Endpoint
+
+
+
+
Status
+
+
+
+
HTTP code
+
+
+
+
Latency
+
+
+
+
Timestamp
+
+
+
+
Raw response
+
+
+
+
+ +
+
+

Event Log

+ 0 checks logged +
+
    +
    +
    + +
    + +
    + + + + + Sources: /health (status & latency) + /metrics (total requests) + + auto-refresh every 15s +
    + +
    + + + + \ No newline at end of file diff --git a/q1-deploy-monitor/dashboard/script.js b/q1-deploy-monitor/dashboard/script.js new file mode 100644 index 0000000..c660dcc --- /dev/null +++ b/q1-deploy-monitor/dashboard/script.js @@ -0,0 +1,690 @@ +/* ========================================================================= + SRE MONITORING DASHBOARD + Vanilla JS. No frameworks, no external chart libraries. + + LIVE DATA SOURCES (NO LOG FILES) + ------------------------------- + 1. /health → { "status": "healthy" } + Live HTTP GET health check. Evaluates service status (Healthy/Unhealthy) + and measures real round-trip latency in milliseconds. + + 2. /metrics → { "requests": } + Live HTTP GET metrics check. Retrieves authoritative total request count + tracked by the Go service backend. + ========================================================================= */ + +const CONFIG = { + primaryBaseUrl: 'https://gheyma-app-2b88268529f9.hosted.ghaymah.systems', + fallbackBaseUrl: 'http://localhost:8080', + healthPath: '/health', + metricsPath: '/metrics', + maxHistoryPoints: 30, // recent health check data points for charts + autoRefreshMs: 15000, + uptimeBuckets: 48, // signature strip check count + requestTimeoutMs: 6000, +}; + +/* ========================================================================= + Persistent Request Cache (localStorage) + Prevents Total Requests from resetting to 0 when Go server restarts/hibernates + ========================================================================= */ + +const CACHE_KEYS = { + CUMULATIVE_TOTAL: 'ghaymah_cumulative_total_requests', + LAST_RAW_COUNT: 'ghaymah_last_raw_server_requests', +}; + +function getStoredTotalRequests() { + try { + const val = localStorage.getItem(CACHE_KEYS.CUMULATIVE_TOTAL); + return val ? parseInt(val, 10) || 0 : 0; + } catch (_) { + return 0; + } +} + +function updateStoredTotalRequests(serverRequests) { + try { + let accumulatedTotal = getStoredTotalRequests(); + const lastRawStr = localStorage.getItem(CACHE_KEYS.LAST_RAW_COUNT); + const lastRaw = lastRawStr !== null ? parseInt(lastRawStr, 10) || 0 : null; + + if (lastRaw === null) { + accumulatedTotal = Math.max(accumulatedTotal, serverRequests); + } else if (serverRequests >= lastRaw) { + const delta = serverRequests - lastRaw; + accumulatedTotal += delta; + } else { + // Server restarted or hibernated (serverCount reset to smaller value) + accumulatedTotal += serverRequests; + } + + localStorage.setItem(CACHE_KEYS.CUMULATIVE_TOTAL, accumulatedTotal.toString()); + localStorage.setItem(CACHE_KEYS.LAST_RAW_COUNT, serverRequests.toString()); + return accumulatedTotal; + } catch (_) { + return serverRequests; + } +} + +/* ========================================================================= + State + ========================================================================= */ + +const state = { + history: [], // [{ok, latencyMs, httpCode, timestamp, raw, error, url}] + events: [], // [{time, level, message}] + totals: { total: getStoredTotalRequests(), success: 0, failed: 0, totalSource: 'metrics' }, + uptimeBuckets: [], // [{state: 'ok'|'down'}] + isChecking: false, + metricsError: null, + activeBaseUrl: CONFIG.primaryBaseUrl, +}; + +/* ========================================================================= + Live HTTP Fetch Helpers + ========================================================================= */ + +async function fetchHealthFromUrl(url) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs); + const startTime = performance.now(); + try { + const res = await fetch(url, { signal: controller.signal, cache: 'no-store' }); + const endTime = performance.now(); + const latencyMs = Math.round(endTime - startTime); + let body = null; + let rawText = ''; + try { + rawText = await res.text(); + body = JSON.parse(rawText); + } catch (_) {} + + const isHealthy = res.ok && body && body.status === 'healthy'; + return { + ok: isHealthy, + httpCode: res.status, + latencyMs, + timestamp: new Date(), + raw: rawText || (body ? JSON.stringify(body) : `HTTP ${res.status}`), + isNetworkError: false, + error: isHealthy ? null : `HTTP ${res.status}${body && body.status ? ` (${body.status})` : ''}`, + url, + }; + } catch (err) { + const endTime = performance.now(); + const latencyMs = Math.round(endTime - startTime); + const isAbort = err.name === 'AbortError'; + const isNetworkError = !isAbort && err.name === 'TypeError'; + return { + ok: false, + httpCode: null, + latencyMs, + timestamp: new Date(), + raw: null, + isNetworkError, + error: isAbort ? 'request timed out' : (isNetworkError ? 'network/CORS error — server unreachable' : (err.message || 'network error')), + url, + }; + } finally { + clearTimeout(timeout); + } +} + +async function fetchMetricsFromUrl(url) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs); + try { + const res = await fetch(url, { signal: controller.signal, cache: 'no-store' }); + if (!res.ok) { + return { ok: false, requests: null, isNetworkError: false, error: `HTTP ${res.status}`, url }; + } + const json = await res.json(); + return { + ok: true, + requests: typeof json.requests === 'number' ? json.requests : null, + isNetworkError: false, + error: null, + url, + }; + } catch (err) { + const isAbort = err.name === 'AbortError'; + const isNetworkError = !isAbort && err.name === 'TypeError'; + return { + ok: false, + requests: null, + isNetworkError, + error: isAbort ? 'request timed out' : (err.message || 'network error'), + url, + }; + } finally { + clearTimeout(timeout); + } +} + +/* ========================================================================= + Smart Dual-Endpoint Fetch (Primary hosted endpoint -> Local fallback) + ========================================================================= */ + +async function fetchHealthWithFallback() { + const bases = [CONFIG.primaryBaseUrl, CONFIG.fallbackBaseUrl].filter(Boolean); + let lastResult = null; + + for (const base of bases) { + const url = `${base}${CONFIG.healthPath}`; + const result = await fetchHealthFromUrl(url); + if (result.ok) { + state.activeBaseUrl = base; + return result; + } + lastResult = result; + } + + return lastResult; +} + +async function fetchMetricsWithFallback() { + const base = state.activeBaseUrl || CONFIG.primaryBaseUrl; + const primaryUrl = `${base}${CONFIG.metricsPath}`; + const primaryRes = await fetchMetricsFromUrl(primaryUrl); + if (primaryRes.ok) return primaryRes; + + const fallbackBase = base === CONFIG.primaryBaseUrl ? CONFIG.fallbackBaseUrl : CONFIG.primaryBaseUrl; + if (fallbackBase) { + const fallbackUrl = `${fallbackBase}${CONFIG.metricsPath}`; + const fallbackRes = await fetchMetricsFromUrl(fallbackUrl); + if (fallbackRes.ok) return fallbackRes; + } + + return primaryRes; +} + +async function refreshFromSources() { + const [healthRes, metricsRes] = await Promise.all([ + fetchHealthWithFallback(), + fetchMetricsWithFallback(), + ]); + + const anyNetworkError = healthRes.isNetworkError || metricsRes.isNetworkError; + if (anyNetworkError) showNetworkWarning(); else hideNetworkWarning(); + + // Push live check result into history + state.history.push(healthRes); + if (state.history.length > CONFIG.maxHistoryPoints) { + state.history.shift(); + } + + // Update uptime bucket tracking + state.uptimeBuckets.push({ state: healthRes.ok ? 'ok' : 'down' }); + if (state.uptimeBuckets.length > CONFIG.uptimeBuckets) { + state.uptimeBuckets.shift(); + } + + // Push new event + state.events.unshift({ + time: healthRes.timestamp, + level: healthRes.ok ? 'ok' : 'bad', + message: healthRes.ok + ? `Health check passed — ${healthRes.latencyMs} ms (${healthRes.url})` + : `Health check failed — ${healthRes.error}`, + }); + if (state.events.length > 50) { + state.events.pop(); + } + + // Update success & failed check counts + const totalChecks = state.history.length; + const successChecks = state.history.filter(h => h.ok).length; + const failedChecks = totalChecks - successChecks; + + state.totals.success = successChecks; + state.totals.failed = failedChecks; + + if (metricsRes.ok && typeof metricsRes.requests === 'number') { + const cumulativeTotal = updateStoredTotalRequests(metricsRes.requests); + state.totals.total = cumulativeTotal; + state.totals.totalSource = 'metrics'; + state.metricsError = null; + } else { + const cached = getStoredTotalRequests(); + if (cached > 0) { + state.totals.total = cached; + } + state.metricsError = metricsRes.error || 'unavailable'; + } +} + +/* ========================================================================= + Formatting helpers + ========================================================================= */ + +const fmtTime = (d) => d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +const fmtShortTime = (d) => d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +const fmtPct = (n) => `${n.toFixed(2)}%`; + +/* ========================================================================= + Render — KPI Cards + ========================================================================= */ + +function renderKPIs() { + const latest = state.history[state.history.length - 1]; + const dot = document.getElementById('topStatusDot'); + const indicator = document.getElementById('statusIndicator'); + const statusText = document.getElementById('statusText'); + const statusFoot = document.getElementById('statusFoot'); + + if (!latest) { + indicator.className = 'status-indicator'; + statusText.textContent = 'Checking…'; + statusText.style.color = 'var(--text-muted)'; + dot.className = 'topbar__dot'; + statusFoot.textContent = 'awaiting first check'; + } else { + const healthy = latest.ok; + indicator.className = 'status-indicator ' + (healthy ? 'is-healthy' : 'is-unhealthy'); + statusText.textContent = healthy ? 'Healthy' : 'Unhealthy'; + statusText.style.color = healthy ? 'var(--ok)' : 'var(--bad)'; + dot.className = 'topbar__dot' + (healthy ? '' : ' is-bad'); + statusFoot.textContent = `checked ${fmtTime(latest.timestamp)}`; + } + + const setText = (id, txt) => { + const el = document.getElementById(id); + if (el) el.textContent = txt; + }; + + setText('responseTimeValue', latest ? `${latest.latencyMs} ms` : '—'); + const avgRecent = average(state.history.slice(-10).map(h => h.latencyMs)); + setText('responseTimeFoot', state.history.length + ? `avg last 10: ${Math.round(avgRecent)} ms` + : 'current'); + + setText('totalRequestsValue', state.totals.total.toLocaleString()); + setText('totalRequestsFoot', state.totals.totalSource === 'metrics' + ? 'reported by /metrics' + : `metrics: ${state.metricsError || 'unavailable'}`); + + setText('successValue', state.totals.success.toLocaleString()); + setText('failedValue', state.totals.failed.toLocaleString()); + + const checksTotal = state.totals.success + state.totals.failed; + const successRate = checksTotal ? (state.totals.success / checksTotal) * 100 : 100; + const failRate = checksTotal ? (state.totals.failed / checksTotal) * 100 : 0; + setText('successRateFoot', fmtPct(successRate) + ' of checks'); + setText('failedRateFoot', fmtPct(failRate) + ' of checks'); + + setText('lastCheckValue', latest ? fmtTime(latest.timestamp) : '—'); + setText('lastCheckFoot', latest + ? (latest.ok ? `HTTP ${latest.httpCode}` : `HTTP ${latest.httpCode ?? '—'} · ${latest.error}`) + : '—'); +} + +function average(arr) { + if (!arr.length) return 0; + return arr.reduce((a, b) => a + b, 0) / arr.length; +} + +/* ========================================================================= + Render — Latest Health Check Panel + ========================================================================= */ + +function renderLatestCheck() { + const latest = state.history[state.history.length - 1]; + const set = (id, text, cls) => { + const el = document.getElementById(id); + if (el) { + el.textContent = text; + el.className = cls ? 'dd-' + cls : ''; + } + }; + if (!latest) return; + set('lcEndpoint', latest.url || `${state.activeBaseUrl}${CONFIG.healthPath}`); + set('lcStatus', latest.ok ? 'healthy' : 'unhealthy', latest.ok ? 'ok' : 'bad'); + set('lcCode', latest.httpCode ?? '—'); + set('lcLatency', `${latest.latencyMs} ms`); + set('lcTime', latest.timestamp.toLocaleString()); + set('lcRaw', latest.raw || latest.error || '—'); +} + +/* ========================================================================= + Render — Events Log + ========================================================================= */ + +function renderEvents() { + const list = document.getElementById('eventsLog'); + if (!list) return; + list.innerHTML = ''; + state.events.slice(0, 25).forEach(ev => { + const li = document.createElement('li'); + li.className = 'events-log__row'; + li.innerHTML = ` + + ${fmtShortTime(ev.time)} + ${ev.message} + `; + list.appendChild(li); + }); + const meta = document.getElementById('eventsMeta'); + if (meta) meta.textContent = `${state.events.length} checks logged`; +} + +/* ========================================================================= + Render — Uptime Strip & Percentage + ========================================================================= */ + +function renderUptime() { + const strip = document.getElementById('uptimeStrip'); + if (!strip) return; + strip.innerHTML = ''; + const buckets = state.uptimeBuckets; + const padding = Math.max(CONFIG.uptimeBuckets - buckets.length, 0); + + for (let i = 0; i < padding; i++) { + const cell = document.createElement('div'); + cell.className = 'uptime-cell is-unknown'; + strip.appendChild(cell); + } + buckets.forEach(b => { + const cell = document.createElement('div'); + cell.className = 'uptime-cell' + (b.state === 'ok' ? '' : ` is-${b.state}`); + strip.appendChild(cell); + }); + + const okCount = buckets.filter(b => b.state === 'ok').length; + const pct = buckets.length ? (okCount / buckets.length) * 100 : 0; + const elPct = document.getElementById('uptimePct'); + if (elPct) elPct.textContent = buckets.length ? fmtPct(pct) : '—'; + + const meta = document.getElementById('uptimeWindowMeta'); + if (meta) meta.textContent = `${buckets.length} live check${buckets.length === 1 ? '' : 's'} recorded`; + + const now = new Date(); + const start = new Date(now.getTime() - CONFIG.uptimeBuckets * CONFIG.autoRefreshMs); + const elStart = document.getElementById('uptimeTickStart'); + if (elStart) elStart.textContent = buckets.length ? fmtShortTime(start) : 'no data yet'; +} + +/* ========================================================================= + Canvas Charts — Shared Setup + ========================================================================= */ + +function prepCanvas(canvas) { + if (!canvas) return null; + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + const w = Math.max(rect.width, 10); + const h = Math.max(rect.height, 10); + canvas.width = w * dpr; + canvas.height = h * dpr; + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + return { ctx, w, h }; +} + +const CSSVAR = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + +function drawChartPlaceholder(ctx, w, h, message) { + ctx.fillStyle = CSSVAR('--text-dim'); + ctx.font = '12px Outfit, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(message, w / 2, h / 2); +} + +/* ---- Line chart: Response Time over recent health checks ---- */ + +function drawResponseTimeChart() { + const canvas = document.getElementById('responseTimeChart'); + if (!canvas) return; + const prep = prepCanvas(canvas); + if (!prep) return; + const { ctx, w, h } = prep; + const data = state.history; + if (!data.length) { + drawChartPlaceholder(ctx, w, h, 'Collecting data…'); + return; + } + + const padL = 34, padR = 10, padT = 12, padB = 20; + const plotW = w - padL - padR; + const plotH = h - padT - padB; + + const values = data.map(d => d.latencyMs); + const maxV = Math.max(...values) * 1.15; + const minV = 0; + + const xFor = (i) => padL + (data.length === 1 ? plotW / 2 : (i / (data.length - 1)) * plotW); + const yFor = (v) => padT + plotH - ((v - minV) / (maxV - minV || 1)) * plotH; + + // Gridlines + y-axis labels + ctx.strokeStyle = CSSVAR('--border-soft'); + ctx.fillStyle = CSSVAR('--text-dim'); + ctx.font = '10px IBM Plex Mono, monospace'; + ctx.textBaseline = 'middle'; + const gridLines = 4; + for (let i = 0; i <= gridLines; i++) { + const v = (maxV / gridLines) * i; + const y = yFor(v); + ctx.beginPath(); + ctx.moveTo(padL, y); + ctx.lineTo(w - padR, y); + ctx.lineWidth = 1; + ctx.stroke(); + ctx.textAlign = 'right'; + ctx.fillText(Math.round(v), padL - 8, y); + } + + // x-axis labels (first / mid / last) + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + [0, Math.floor((data.length - 1) / 2), data.length - 1].forEach(i => { + if (i < 0 || i >= data.length) return; + ctx.fillText(fmtShortTime(data[i].timestamp), xFor(i), h - padB + 6); + }); + + // Gradient area fill + const grad = ctx.createLinearGradient(0, padT, 0, padT + plotH); + grad.addColorStop(0, 'rgba(91,157,249,0.28)'); + grad.addColorStop(1, 'rgba(91,157,249,0.0)'); + ctx.beginPath(); + data.forEach((d, i) => { + const x = xFor(i), y = yFor(d.latencyMs); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + }); + ctx.lineTo(xFor(data.length - 1), padT + plotH); + ctx.lineTo(xFor(0), padT + plotH); + ctx.closePath(); + ctx.fillStyle = grad; + ctx.fill(); + + // Line + ctx.beginPath(); + data.forEach((d, i) => { + const x = xFor(i), y = yFor(d.latencyMs); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + }); + ctx.strokeStyle = CSSVAR('--info'); + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // Data points + data.forEach((d, i) => { + const x = xFor(i), y = yFor(d.latencyMs); + ctx.beginPath(); + ctx.arc(x, y, d.ok ? 2.2 : 3.4, 0, Math.PI * 2); + ctx.fillStyle = d.ok ? CSSVAR('--info') : CSSVAR('--bad'); + ctx.fill(); + }); +} + +/* ---- Status timeline: pass/fail history ---- */ + +function renderStatusTimeline() { + const strip = document.getElementById('statusTimeline'); + if (!strip) return; + + const data = state.history; + strip.innerHTML = ''; + + const passEl = document.getElementById('statusPassCount'); + const failEl = document.getElementById('statusFailCount'); + const tickStart = document.getElementById('statusTickStart'); + + const passCount = data.filter(d => d.ok).length; + const failCount = data.length - passCount; + + if (passEl) passEl.textContent = passCount.toLocaleString(); + if (failEl) failEl.textContent = failCount.toLocaleString(); + + if (!data.length) { + if (tickStart) tickStart.textContent = 'no data yet'; + return; + } + + const padding = Math.max(CONFIG.maxHistoryPoints - data.length, 0); + for (let i = 0; i < padding; i++) { + const cell = document.createElement('div'); + cell.className = 'status-timeline__cell is-pending'; + cell.title = 'Awaiting check'; + cell.setAttribute('aria-label', 'Awaiting check'); + strip.appendChild(cell); + } + + data.forEach((d) => { + const cell = document.createElement('div'); + cell.className = 'status-timeline__cell' + (d.ok ? '' : ' is-fail'); + const label = d.ok ? 'Healthy' : 'Unhealthy'; + const tip = `${fmtTime(d.timestamp)} · ${label} · ${d.latencyMs} ms`; + cell.title = tip; + cell.setAttribute('aria-label', tip); + cell.tabIndex = 0; + strip.appendChild(cell); + }); + + if (tickStart) tickStart.textContent = fmtShortTime(data[0].timestamp); +} + +/* ---- Doughnut chart: Success vs Failure Ratio ---- */ + +function drawDoughnut() { + const canvas = document.getElementById('statsDoughnut'); + if (!canvas) return; + const prep = prepCanvas(canvas); + if (!prep) return; + const { ctx, w, h } = prep; + const { success, failed } = state.totals; + const total = success + failed; + const cx = w / 2, cy = h / 2; + const radius = Math.min(w, h) / 2 - 8; + const thickness = radius * 0.34; + + if (!total) { + ctx.strokeStyle = CSSVAR('--border-soft'); + ctx.lineWidth = thickness; + ctx.beginPath(); + ctx.arc(cx, cy, radius - thickness / 2, 0, Math.PI * 2); + ctx.stroke(); + drawChartPlaceholder(ctx, w, h, 'No checks yet'); + return; + } + + const successFrac = success / total; + const segments = [ + { frac: successFrac, color: CSSVAR('--ok') }, + { frac: 1 - successFrac, color: CSSVAR('--bad') }, + ]; + + let start = -Math.PI / 2; + segments.forEach(seg => { + if (seg.frac <= 0) return; + const end = start + seg.frac * Math.PI * 2; + ctx.beginPath(); + ctx.arc(cx, cy, radius - thickness / 2, start, end); + ctx.lineWidth = thickness; + ctx.strokeStyle = seg.color; + ctx.lineCap = segments.length > 1 && seg.frac < 1 ? 'butt' : 'round'; + ctx.stroke(); + start = end; + }); + + const centerVal = document.getElementById('doughnutCenterValue'); + if (centerVal) centerVal.textContent = fmtPct(successFrac * 100); +} + +/* ========================================================================= + Network/CORS Warning Banner + ========================================================================= */ + +function showNetworkWarning() { + const el = document.getElementById('networkWarning'); + if (el) el.hidden = false; +} + +function hideNetworkWarning() { + const el = document.getElementById('networkWarning'); + if (el) el.hidden = true; +} + +/* ========================================================================= + Master Render + ========================================================================= */ + +function renderAll() { + renderKPIs(); + renderLatestCheck(); + renderEvents(); + renderUptime(); + drawResponseTimeChart(); + renderStatusTimeline(); + drawDoughnut(); + const elUpdated = document.getElementById('lastUpdated'); + if (elUpdated) elUpdated.textContent = new Date().toLocaleTimeString(); +} + +/* ========================================================================= + Refresh Flow + ========================================================================= */ + +async function doRefresh() { + if (state.isChecking) return; + state.isChecking = true; + + const btn = document.getElementById('refreshBtn'); + btn.classList.add('is-spinning'); + btn.disabled = true; + + try { + await refreshFromSources(); + renderAll(); + } finally { + state.isChecking = false; + btn.classList.remove('is-spinning'); + btn.disabled = false; + } +} + +/* ========================================================================= + Init + ========================================================================= */ + +function init() { + renderAll(); // Initial empty state paint + + document.getElementById('refreshBtn').addEventListener('click', doRefresh); + window.addEventListener('resize', debounce(renderAll, 150)); + + doRefresh(); // Trigger first live check + setInterval(doRefresh, CONFIG.autoRefreshMs); +} + +function debounce(fn, ms) { + let t; + return (...args) => { + clearTimeout(t); + t = setTimeout(() => fn(...args), ms); + }; +} + +document.addEventListener('DOMContentLoaded', init); \ No newline at end of file diff --git a/q1-deploy-monitor/dashboard/style.css b/q1-deploy-monitor/dashboard/style.css new file mode 100644 index 0000000..0bc682a --- /dev/null +++ b/q1-deploy-monitor/dashboard/style.css @@ -0,0 +1,915 @@ +/* ========================================================= GHAYMAH CLOUD THEME DESIGN SYSTEM + Modern Atmospheric Glassmorphism & Sky Blue Accents + ========================================================= */ +:root { + /* Cloud Palette */ + --bg: #070B16; + --bg-gradient: radial-gradient(ellipse at 50% 0%, #0F1932 0%, #070B16 75%); + --surface: rgba(15, 23, 42, 0.65); + --surface-raised: rgba(25, 38, 68, 0.55); + --surface-hover: rgba(30, 48, 86, 0.75); + + --border: rgba(56, 139, 253, 0.18); + --border-glow: rgba(56, 139, 253, 0.45); + --border-soft: rgba(255, 255, 255, 0.06); + + --text: #F0F6FC; + --text-muted: #8B949E; + --text-dim: #6E7681; + --cloud-accent: #388BFD; + --cloud-cyan: #00E5FF; + --cloud-light: #C9D1D9; + + --ok: #39D353; + --ok-glow: rgba(57, 211, 83, 0.25); + --bad: #F85149; + --bad-glow: rgba(248, 81, 73, 0.25); + --warn: #D29922; + --info: #58A6FF; + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + + --font-ui: 'Outfit', 'Plus Jakarta Sans', -apple-system, sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + min-height: 100vh; + background: var(--bg); + background-image: var(--bg-gradient); + color: var(--text); + font-family: var(--font-ui); + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +.mono { + font-family: var(--font-mono); +} + +/* ========================================================= + Ambient Floating Cloud Background + ========================================================= */ +.cloud-bg { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.cloud-orb { + position: absolute; + border-radius: 50%; + filter: blur(90px); + opacity: 0.28; + animation: floatCloud 24s ease-in-out infinite alternate; +} + +.cloud-orb--1 { + width: 500px; + height: 500px; + background: radial-gradient(circle, #388BFD 0%, rgba(0,229,255,0.4) 100%); + top: -150px; + left: 10%; +} + +.cloud-orb--2 { + width: 600px; + height: 600px; + background: radial-gradient(circle, #1F6FEB 0%, rgba(88,166,255,0.3) 100%); + bottom: -200px; + right: 5%; + animation-delay: -8s; + animation-duration: 30s; +} + +.cloud-orb--3 { + width: 350px; + height: 350px; + background: radial-gradient(circle, #00E5FF 0%, rgba(56,139,253,0.3) 100%); + top: 40%; + left: 50%; + transform: translate(-50%, -50%); + animation-delay: -15s; + animation-duration: 22s; + opacity: 0.18; +} + +@keyframes floatCloud { + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(40px, 30px) scale(1.08); } + 100% { transform: translate(-30px, -20px) scale(0.95); } +} + +/* ========================================================= + Layout Shell + ========================================================= */ +.app { + position: relative; + z-index: 1; + max-width: 1200px; + margin: 0 auto; + padding: 32px 24px 60px; +} + +.content { + display: flex; + flex-direction: column; + gap: 24px; + margin-top: 28px; +} + +.banner { + margin-top: 20px; + padding: 14px 18px; + border-radius: var(--radius-md); + font-size: 13px; + line-height: 1.5; + backdrop-filter: blur(12px); +} + +.banner--warn { + background: rgba(210, 153, 34, 0.12); + border: 1px solid rgba(210, 153, 34, 0.35); + color: #F0E2A0; +} +.banner--warn strong { + color: var(--warn); +} + +/* ========================================================= + Top Bar (Cloud Header) + ========================================================= */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 20px 26px; + background: var(--surface); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.08); + flex-wrap: wrap; + transition: border-color 0.3s ease; +} + +.topbar:hover { + border-color: var(--border-glow); +} + +.topbar__identity { + display: flex; + align-items: center; + gap: 14px; +} + +.topbar__logo { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(56, 139, 253, 0.25), rgba(0, 229, 255, 0.15)); + border: 1px solid rgba(56, 139, 253, 0.35); + color: var(--cloud-cyan); + box-shadow: 0 4px 12px rgba(0, 229, 255, 0.15); +} + +.cloud-logo-icon { + filter: drop-shadow(0 2px 6px rgba(0, 229, 255, 0.4)); +} + +.topbar__dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--ok); + box-shadow: 0 0 12px var(--ok); + flex-shrink: 0; + transition: background 0.3s, box-shadow 0.3s; +} +.topbar__dot.is-bad { + background: var(--bad); + box-shadow: 0 0 12px var(--bad); +} + +.topbar__title { + font-size: 19px; + font-weight: 700; + margin: 0; + letter-spacing: -0.02em; + background: linear-gradient(135deg, #FFFFFF 30%, #A5C7FF 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.topbar__subtitle { + margin: 3px 0 0; + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.topbar__actions { + display: flex; + align-items: center; + gap: 22px; +} + +.topbar__updated { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} +.topbar__updated .label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + font-weight: 600; +} +.topbar__updated .mono { + font-size: 12.5px; + color: var(--cloud-light); +} + +.btn { + display: inline-flex; + align-items: center; + gap: 9px; + background: linear-gradient(135deg, rgba(56, 139, 253, 0.18), rgba(25, 38, 68, 0.6)); + border: 1px solid var(--border); + color: var(--text); + padding: 10px 18px; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 600; + cursor: pointer; + backdrop-filter: blur(8px); + transition: all 0.25s ease; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); +} +.btn:hover { + border-color: var(--cloud-accent); + background: linear-gradient(135deg, rgba(56, 139, 253, 0.3), rgba(0, 229, 255, 0.2)); + color: #FFFFFF; + box-shadow: 0 6px 20px rgba(56, 139, 253, 0.25); + transform: translateY(-1px); +} +.btn:active { + transform: translateY(1px); +} +.btn__icon { + color: var(--cloud-cyan); + transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} +.btn.is-spinning .btn__icon { + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ========================================================= + KPI Grid & Cloud Cards + ========================================================= */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +.card { + background: var(--surface); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 24px 26px; + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06); + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + position: relative; + overflow: hidden; +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--cloud-accent), transparent); + opacity: 0; + transition: opacity 0.3s ease; +} + +.card:hover { + transform: translateY(-5px); + border-color: var(--border-glow); + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.45), 0 0 24px rgba(56, 139, 253, 0.15); +} + +.card:hover::before { + opacity: 1; +} + +.kpi { + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 140px; + gap: 12px; +} + +.kpi__head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.kpi__label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + font-weight: 700; +} + +.kpi__icon { + color: var(--cloud-accent); + opacity: 0.85; +} + +.kpi__value { + font-size: 32px; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1; + color: #FFFFFF; +} + +.kpi__foot { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Status Indicator Card */ +.kpi__status-row { + display: flex; + align-items: center; + gap: 12px; +} + +.status-indicator { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--text-dim); + flex-shrink: 0; + position: relative; +} + +.status-indicator.is-healthy { + background: var(--ok); + box-shadow: 0 0 16px var(--ok-glow); +} +.status-indicator.is-healthy::after { + content: ''; + position: absolute; + inset: -5px; + border-radius: 50%; + border: 1.5px solid var(--ok); + animation: pulse-ring 2.2s ease-out infinite; +} + +.status-indicator.is-unhealthy { + background: var(--bad); + box-shadow: 0 0 16px var(--bad-glow); +} + +@keyframes pulse-ring { + 0% { transform: scale(0.8); opacity: 0.9; } + 100% { transform: scale(1.7); opacity: 0; } +} + +.kpi__status-text { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; +} + +/* ========================================================= + Panels, Charts & Data Viz + ========================================================= */ +.charts-grid, +.stats-grid, +.detail-grid { + display: grid; + gap: 20px; +} + +.charts-grid { + grid-template-columns: repeat(2, 1fr); +} + +.stats-grid, +.detail-grid { + grid-template-columns: 1.4fr 1fr; +} + +.panel { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; +} + +.panel__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.panel__title { + margin: 0; + font-size: 15px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--cloud-light); +} + +.panel__meta { + font-size: 11px; + color: var(--text-dim); + font-family: var(--font-mono); +} + +.chart-wrap { + position: relative; + width: 100%; + height: 220px; +} + +.chart-canvas { + display: block; + width: 100%; + height: 100%; +} + +/* Check results timeline */ +.status-timeline-wrap { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 220px; + justify-content: center; +} + +.status-timeline-summary { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; +} + +.status-timeline-stat { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-muted); +} + +.status-timeline-stat .mono { + font-size: 18px; + font-weight: 700; + color: #FFFFFF; +} + +.status-timeline-stat--ok .mono { + color: var(--ok); +} + +.status-timeline-stat--bad .mono { + color: var(--bad); +} + +.status-timeline-stat__dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-timeline-stat--ok .status-timeline-stat__dot { + background: var(--ok); + box-shadow: 0 0 8px var(--ok-glow); +} + +.status-timeline-stat--bad .status-timeline-stat__dot { + background: var(--bad); + box-shadow: 0 0 8px var(--bad-glow); +} + +.status-timeline { + display: flex; + align-items: stretch; + gap: 4px; + height: 48px; + padding: 4px; + background: rgba(0, 0, 0, 0.22); + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); + overflow-x: auto; + scrollbar-width: thin; +} + +.status-timeline:empty::after { + content: 'Collecting data…'; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + font-size: 12px; + color: var(--text-dim); +} + +.status-timeline__cell { + flex: 1 1 0; + min-width: 6px; + max-width: 24px; + border-radius: 4px; + background: var(--ok); + opacity: 0.9; + cursor: default; + transition: transform 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease; +} + +.status-timeline__cell:hover, +.status-timeline__cell:focus-visible { + transform: scaleY(1.08); + opacity: 1; + box-shadow: 0 0 10px var(--ok-glow); + outline: none; +} + +.status-timeline__cell.is-fail { + background: var(--bad); +} + +.status-timeline__cell.is-fail:hover, +.status-timeline__cell.is-fail:focus-visible { + box-shadow: 0 0 10px var(--bad-glow); +} + +.status-timeline__cell.is-pending { + background: rgba(255, 255, 255, 0.08); + opacity: 1; +} + +.status-timeline__cell.is-pending:hover, +.status-timeline__cell.is-pending:focus-visible { + box-shadow: none; + transform: none; +} + +.status-timeline-axis { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-dim); + font-family: var(--font-mono); +} + +.doughnut-wrap { + position: relative; + width: 100%; + max-width: 180px; + height: 180px; + margin: 0 auto; +} + +.doughnut-canvas { + width: 100%; + height: 100%; +} + +.doughnut-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.doughnut-center__value { + font-size: 22px; + font-weight: 700; + color: #FFFFFF; + line-height: 1; +} + +.doughnut-center__label { + margin-top: 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + font-weight: 600; +} + +.check-stats { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 4px; + border-top: 1px solid var(--border-soft); +} + +.check-stats__row { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + font-size: 13px; +} + +.check-stats__label { + color: var(--text-muted); + min-width: 52px; +} + +.check-stats__value { + font-weight: 600; + color: #FFFFFF; +} + +.check-stats__foot { + font-size: 11px; + color: var(--text-dim); + font-family: var(--font-mono); +} + +/* Uptime strip */ +.uptime-summary { + display: flex; + align-items: baseline; + gap: 10px; +} + +.uptime-pct { + font-size: 36px; + font-weight: 700; + color: var(--ok); + line-height: 1; + letter-spacing: -0.03em; +} + +.uptime-label { + font-size: 12px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.uptime-strip { + display: grid; + grid-template-columns: repeat(48, 1fr); + gap: 3px; + height: 36px; + align-items: stretch; +} + +.uptime-cell { + border-radius: 3px; + background: var(--ok); + opacity: 0.85; + min-width: 0; + transition: opacity 0.2s ease; +} + +.uptime-cell.is-down { + background: var(--bad); +} + +.uptime-cell.is-unknown { + background: rgba(255, 255, 255, 0.08); + opacity: 1; +} + +.uptime-axis { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-dim); + font-family: var(--font-mono); +} + +/* Detail list */ +.detail-list { + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.detail-list__row { + display: grid; + grid-template-columns: 110px 1fr; + gap: 12px; + align-items: start; +} + +.detail-list__row--full { + grid-template-columns: 1fr; +} + +.detail-list dt { + margin: 0; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + font-weight: 600; +} + +.detail-list dd { + margin: 0; + font-size: 13px; + color: var(--cloud-light); + word-break: break-all; +} + +.detail-list dd.dd-ok { + color: var(--ok); + font-weight: 600; + text-transform: capitalize; +} + +.detail-list dd.dd-bad { + color: var(--bad); + font-weight: 600; + text-transform: capitalize; +} + +.detail-list__raw { + padding: 10px 12px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + font-size: 12px; + line-height: 1.5; + max-height: 120px; + overflow-y: auto; +} + +/* Events log */ +.panel--events { + min-height: 320px; +} + +.events-log { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 280px; + overflow-y: auto; +} + +.events-log__row { + display: grid; + grid-template-columns: 8px 52px 1fr; + gap: 10px; + align-items: start; + padding: 8px 10px; + border-radius: var(--radius-sm); + background: rgba(0, 0, 0, 0.18); + border: 1px solid var(--border-soft); + font-size: 12px; + line-height: 1.45; +} + +.events-log__dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 4px; + flex-shrink: 0; +} + +.events-log__dot--ok { + background: var(--ok); + box-shadow: 0 0 8px var(--ok-glow); +} + +.events-log__dot--bad { + background: var(--bad); + box-shadow: 0 0 8px var(--bad-glow); +} + +.events-log__time { + font-family: var(--font-mono); + color: var(--text-dim); + white-space: nowrap; +} + +.events-log__msg { + color: var(--cloud-light); +} + +.events-log__msg strong { + color: #FFFFFF; + font-weight: 600; +} + +/* ========================================================= + Footer + ========================================================= */ +.app-footer { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid var(--border-soft); + font-size: 12px; + color: var(--text-dim); + font-family: var(--font-mono); +} + +.footer-cloud-icon { + vertical-align: middle; + margin-right: 4px; + color: var(--cloud-accent); +} + +/* ========================================================= + Responsive Layout + ========================================================= */ +@media (max-width: 900px) { + .kpi-grid { + grid-template-columns: 1fr; + } + .charts-grid, + .stats-grid, + .detail-grid { + grid-template-columns: 1fr; + } + .app { + padding: 20px 16px 40px; + } + .topbar { + padding: 16px 20px; + } + .topbar__actions { + width: 100%; + justify-content: space-between; + } + .kpi__value { + font-size: 22px; + } + .uptime-strip { + grid-template-columns: repeat(24, 1fr); + } +} + +@media (max-width: 480px) { + .app { + padding: 20px 14px 32px; + } + .topbar { + padding: 14px; + } +} + +@media (prefers-reduced-motion: reduce){ + *{ animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; } +} \ No newline at end of file diff --git a/q1-deploy-monitor/health-check.sh b/q1-deploy-monitor/health-check.sh new file mode 100644 index 0000000..3667af9 --- /dev/null +++ b/q1-deploy-monitor/health-check.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# URL of the application's health endpoint +URL="https://gheyma-app-2b88268529f9.hosted.ghaymah.systems/health" + +# Maximum time to wait for a response (seconds) +TIMEOUT=10 + +# Monitoring interval (seconds) +INTERVAL=30 + +# Log file +LOG_FILE="monitor.log" + +# Continuously monitor the application +while true; do + + # Get HTTP status code and response time + RESPONSE=$(curl -s \ + --max-time "$TIMEOUT" \ + -o /dev/null \ + -w "%{http_code} %{time_total}" \ + "$URL") + + # Check if curl was successful + if [ $? -ne 0 ]; then + + # Server is unreachable or request timed out + MESSAGE="$(date '+%Y-%m-%d %H:%M:%S') - UNREACHABLE - Connection failed or timed out" + + else + + # Extract HTTP status code + STATUS=$(echo "$RESPONSE" | awk '{print $1}') + + # Extract response time + RESPONSE_TIME=$(echo "$RESPONSE" | awk '{print $2}') + + # Check application health + if [ "$STATUS" = "200" ]; then + MESSAGE="$(date '+%Y-%m-%d %H:%M:%S') - HEALTHY - Status: $STATUS - Response time: ${RESPONSE_TIME}s" + else + MESSAGE="$(date '+%Y-%m-%d %H:%M:%S') - UNHEALTHY - Status: $STATUS - Response time: ${RESPONSE_TIME}s" + fi + fi + + # Print result to terminal + echo "$MESSAGE" + + # Save result to log file + echo "$MESSAGE" >> "$LOG_FILE" + + # Wait before the next health check + sleep "$INTERVAL" + +done \ No newline at end of file diff --git a/q1-deploy-monitor/monitor.log b/q1-deploy-monitor/monitor.log new file mode 100644 index 0000000..64aa72f --- /dev/null +++ b/q1-deploy-monitor/monitor.log @@ -0,0 +1,18 @@ +2026-07-26 16:25:12 - HEALTHY - Status: 200 - Response time: 0.470504s +2026-07-26 16:25:43 - HEALTHY - Status: 200 - Response time: 0.768556s +2026-07-26 16:26:13 - UNHEALTHY - Status: 503 - Response time: 0.477449s +2026-07-26 16:26:44 - UNHEALTHY - Status: 503 - Response time: 0.478812s +2026-07-26 16:27:15 - UNHEALTHY - Status: 503 - Response time: 0.458785s +2026-07-26 16:27:45 - HEALTHY - Status: 200 - Response time: 0.466478s +2026-07-26 16:28:16 - HEALTHY - Status: 200 - Response time: 0.451787s +2026-07-26 16:28:47 - HEALTHY - Status: 200 - Response time: 0.758977s +2026-07-26 16:29:49 - HEALTHY - Status: 200 - Response time: 0.457703s +2026-07-26 17:29:52 - HEALTHY - Status: 200 - Response time: 0.708505s +2026-07-26 17:30:23 - HEALTHY - Status: 200 - Response time: 0.465759s +2026-07-26 17:30:53 - HEALTHY - Status: 200 - Response time: 0.460067s +2026-07-26 17:31:25 - HEALTHY - Status: 200 - Response time: 1.005199s +2026-07-26 17:31:55 - HEALTHY - Status: 200 - Response time: 0.499397s +2026-07-26 17:32:26 - HEALTHY - Status: 200 - Response time: 0.437532s +2026-07-26 17:32:57 - HEALTHY - Status: 200 - Response time: 0.906311s +2026-07-26 17:33:28 - HEALTHY - Status: 200 - Response time: 0.431620s +2026-07-26 18:10:49 - HEALTHY - Status: 200 - Response time: 0.537184s diff --git a/q2-postmortem/postmortem-report.md b/q2-postmortem/postmortem-report.md new file mode 100644 index 0000000..64adebc --- /dev/null +++ b/q2-postmortem/postmortem-report.md @@ -0,0 +1,73 @@ +# Outage Postmortem: Repeated OOMKilled Events + +## 1. Summary + +On the day of the incident, a cloud-hosted application went down for 45 minutes. The root cause was repeated OOMKilled restarts: the container's memory usage exceeded its configured limit, the orchestrator killed the process, and the pod restarted in a loop instead of recovering. Users saw failed requests and timeouts for the full 45 minutes until the underlying memory limit was raised and the workload stabilized. + +**Severity:** High + +**Duration:** 45 minutes + +**Root cause:** Container memory limit set below actual working-set memory, triggering a restart loop under load + +**Status:** Resolved, follow-up actions listed below + +## 2. Timeline + +| Time | Event | +|---|---| +| 10:43 am | Traffic to the service increases beyond typical daily peak | +| 10:53 am | First pod is OOMKilled; Kubernetes restarts it automatically | +| 11:03 am | Memory usage climbs again post-restart, pod is killed a second time | +| 11:08 am | Error rate alert fires; on-call engineer is paged | +| 11:15 am | On-call confirms multiple pods are in `CrashLoopBackOff` | +| 11:20 am | Logs and metrics dashboards confirm OOMKilled as the kill reason across all affected pods | +| 11:23 am | Engineer raises the memory limit and requests for the deployment and applies the change | +| 11:25 am | New pods start successfully and stay up under load | +| 11:28 am | Error rate returns to baseline; incident closed | + +## 3. Root Cause Analysis + +The service's memory limit had been set months earlier, based on load testing done before a feature update that added an in-memory cache. That cache grew with traffic and was never accounted for when the limit was set. Under normal daily traffic the service stayed under the limit. During a traffic spike, memory usage crossed the limit, the container was killed, and the fresh pod inherited the same undersized limit, so it hit the same wall again as soon as traffic resumed. This produced the crash loop. + +Contributing factors: + +- No memory-usage alert existed below the hard limit, so the team had no early warning before pods started dying. +- The horizontal pod autoscaler was configured on CPU usage only, not memory, so it did not add replicas to spread the load. +- Readiness and liveness probes were not tuned to fail fast on repeated restarts, which let the crash loop continue rather than triggering an automatic rollback. + +## 4. Recommendations + +1. Increase the memory limit and request to match current working-set usage plus headroom, based on the cache's real memory footprint under peak load. +2. Add a memory-usage alert at 75–80% of the limit so the team is warned before a pod is killed, not after. +3. Add memory as a second metric for the horizontal pod autoscaler, alongside CPU. +4. Set a restart-count threshold that triggers an automatic rollback to the previous deployment if a pod restarts more than a set number of times within a short window. +5. Load-test the service again after any change that adds in-memory state (caches, buffers, queues), since this is what caused the original limit to go stale. + +## 5. Auto-Scaling Policy to Prevent Recurrence + +**Goal:** scale on the resource that actually caused the incident (memory), not only on CPU, and react before pods are killed. + +**Policy design:** + +- **Metrics used:** CPU utilization and memory utilization, both tracked per pod against their requested values. +- **Scale-out trigger:** add replicas when either CPU exceeds 65% of request or memory exceeds 70% of the limit, sustained for 60 seconds. Using two metrics means a memory-bound spike triggers scaling even if CPU stays low, which is what happened during the incident. +- **Scale-in trigger:** remove replicas only when both CPU and memory stay below 40% of their configured values for 5 minutes, to avoid flapping. +- **Minimum and maximum replicas:** set a minimum that covers baseline traffic without cold starts, and a maximum sized to the platform's account or namespace resource quota, so scaling out cannot silently run into a quota wall during a spike. +- **Pod Disruption Budget:** configure a PDB so scale-in and node maintenance never drop available replicas below the minimum needed to serve traffic. +- **Memory limit and request gap:** keep a buffer between the memory request and the hard limit (for example, request at 70% of the limit) so a short-lived spike has room to breathe before the orchestrator kills the pod. +- **Restart-based circuit breaker:** if a pod restarts more than 3 times in 10 minutes, stop scaling that deployment further and page the on-call team instead, since more replicas of a broken configuration will not fix a bad memory limit. + +This combination directly targets the incident: memory-aware scaling reacts to the same signal that caused the crash loop, the request/limit buffer gives pods room before they get killed, and the restart-based breaker stops the system from scaling into the same failure instead of alerting on it. + +## 6. Early Detection with Cloud Monitoring Tools + +The incident could have been caught well before the OOMKilled events with the following setup: + +- **Memory usage alerts below the hard limit.** An alert at 75–80% of the container's memory limit gives the team a warning window instead of finding out only after a kill event. This is the single biggest gap in this incident: the limit existed, but nothing watched the approach to it. +- **Restart and crash-loop alerts.** Most cloud monitoring stacks can alert directly on container restart counts or on the `OOMKilled` reason code from the orchestrator. Alerting on the restart reason, not just "pod restarted," would have flagged this as a memory problem within the first minute. +- **Dashboards that separate CPU and memory per workload.** A single combined "resource usage" dashboard can hide a memory-only problem if CPU looks fine. Splitting the two, and showing them against their configured request/limit lines rather than raw numbers, makes a memory ceiling visible at a glance. +- **Trend-based, not just threshold-based, alerts.** A memory-usage graph that climbs steadily after each deploy is a leading indicator of a slow leak or an undersized limit, even before it crosses any alert threshold. Comparing memory usage before and after each release catches this kind of regression at deploy time instead of at the next traffic spike. +- **Synthetic load or canary checks after deploys.** Running a short load test against a new deployment before it takes full production traffic would have surfaced the higher memory footprint from the cache change immediately, rather than weeks later during an organic traffic spike. + +Together, these close the two gaps that let this incident happen: no visibility into memory approaching its limit, and no mechanism that reacted to memory pressure before the orchestrator did. \ No newline at end of file diff --git a/q3-cicd/readme.md b/q3-cicd/readme.md new file mode 100644 index 0000000..901cd0d --- /dev/null +++ b/q3-cicd/readme.md @@ -0,0 +1,80 @@ +# بناء CI/CD Pipeline على غيمة + +## 1. GitHub Actions Workflow + +تم إنشاء GitHub Actions Workflow لأتمتة عملية بناء واختبار ونشر التطبيق على منصة Ghaymah Cloud. + +يتم تشغيل الـ Workflow عند عمل `push` على فرعي `main` أو `develop`. + +الخطوات الأساسية هي: + +1. تحميل الكود من GitHub. +2. بناء Docker Image باستخدام `Dockerfile`. +3. تشغيل Container بشكل مؤقت واختبار `/health` للتأكد من أن التطبيق يعمل. +4. نشر التطبيق على بيئة Staging عند استخدام فرع `develop`. +5. نشر التطبيق على Production عند استخدام فرع `main` بعد الحصول على موافقة يدوية. + +يتم تخزين بيانات تسجيل الدخول الخاصة بـ Ghaymah داخل GitHub Secrets باستخدام: + +* `GHAYMAH_EMAIL` +* `GHAYMAH_PW` + +--- + +## 2. Manual Approval قبل Production + +تم استخدام GitHub Environment باسم `production` لإضافة مرحلة موافقة يدوية قبل النشر. + +عند وصول الكود إلى فرع `main`، يتم تنفيذ عملية البناء والاختبار أولاً، ثم يتوقف الـ Workflow وينتظر موافقة الشخص المسؤول. + +بعد الموافقة فقط يتم تنفيذ عملية النشر على Production باستخدام Ghaymah CLI. + +هذا يمنع نشر أي إصدار جديد على Production بشكل تلقائي دون مراجعة. + +--- + +## 3. الفرق بين Staging و Production + +### Staging + +بيئة تستخدم لاختبار التطبيق قبل نشره للمستخدمين الحقيقيين. + +يتم نشر التغييرات عليها بشكل تلقائي من فرع `develop`، ويمكن استخدامها للتأكد من أن التطبيق يعمل بشكل صحيح. + +### Production + +هي البيئة التي يستخدمها المستخدمون الحقيقيون. + +يتم النشر إليها من فرع `main`، ويتم وضع Manual Approval قبل عملية النشر لتقليل احتمالية نشر إصدار يحتوي على أخطاء. + +الـ Workflow يكون كالتالي: + +`develop → Build & Test → Staging` + +`main → Build & Test → Manual Approval → Production` + +--- + +## 4. الربط مع Ghaymah CLI + +يتم تثبيت Ghaymah CLI داخل GitHub Actions Runner باستخدام الأمر: + +`curl -sSL https://cli.ghaymah.systems/install.sh | bash` + +بعد ذلك يتم تسجيل الدخول باستخدام بيانات Ghaymah المخزنة في GitHub Secrets: + +`gy auth login` + +ثم يتم تنفيذ عملية النشر باستخدام: + +`gy resource app launch` + +يستخدم Ghaymah CLI ملف `.ghaymah.json` الموجود في جذر المشروع لمعرفة إعدادات التطبيق، مثل: + +* اسم التطبيق. +* Project ID. +* الـ Port المستخدم. +* اسم Dockerfile. +* إعدادات الوصول العام. + +وبذلك يصبح GitHub Actions مسؤولاً عن أتمتة عملية CI/CD، بينما يقوم Ghaymah CLI بتنفيذ عملية النشر على منصة Ghaymah Cloud. diff --git a/q3-cicd/workflow.yml b/q3-cicd/workflow.yml new file mode 100644 index 0000000..1d9c571 --- /dev/null +++ b/q3-cicd/workflow.yml @@ -0,0 +1,87 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + - develop + +jobs: + build: + name: Build Docker Image + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5.0.0 + + - name: Build Docker image + run: | + docker build -t go-app:${{ github.sha }} . + + - name: Test Docker image + run: | + docker run -d \ + --name go-app-test \ + -p 8080:8080 \ + go-app:${{ github.sha }} + + sleep 3 + + curl --fail http://localhost:8080/health + + docker stop go-app-test + docker rm go-app-test + + + deploy-staging: + name: Deploy to Staging + needs: build + if: github.ref == 'refs/heads/develop' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5.0.0 + + - name: Install Ghaymah CLI + run: | + curl -sSL https://cli.ghaymah.systems/install.sh | bash + + - name: Login to Ghaymah + run: | + $HOME/ghaymah/bin/gy auth login \ + --email "${{ secrets.GHAYMAH_EMAIL }}" \ + --password "${{ secrets.GHAYMAH_PW }}" + + - name: Deploy to Staging + run: | + $HOME/ghaymah/bin/gy resource app launch + + + deploy-production: + name: Deploy to Production + needs: build + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + + environment: + name: production + + steps: + - name: Checkout repository + uses: actions/checkout@v5.0.0 + + - name: Install Ghaymah CLI + run: | + curl -sSL https://cli.ghaymah.systems/install.sh | bash + + - name: Login to Ghaymah + run: | + $HOME/ghaymah/bin/gy auth login \ + --email "${{ secrets.GHAYMAH_EMAIL }}" \ + --password "${{ secrets.GHAYMAH_PW }}" + + - name: Deploy to Production + run: | + $HOME/ghaymah/bin/gy resource app launch \ No newline at end of file diff --git a/q4-scalability/architecture.png b/q4-scalability/architecture.png new file mode 100644 index 0000000..1f79a4b Binary files /dev/null and b/q4-scalability/architecture.png differ diff --git a/q4-scalability/calculations.md b/q4-scalability/calculations.md new file mode 100644 index 0000000..65ff180 --- /dev/null +++ b/q4-scalability/calculations.md @@ -0,0 +1,41 @@ +# Scalability and Load Balancing + +## 2. Calculating the Number of Containers Required + +**Given:** +- Total load: 15,000 req/s +- Capacity per container: 500 req/s +- Safety margin: 30% + +**Calculation:** + +``` +Effective load = 15,000 × 1.3 = 19,500 req/s +Number of containers = 19,500 ÷ 500 = 39 containers +``` + +**Result: 39 containers** + +Check: 15,000 ÷ 39 ≈ 385 req/s per container (≈ 77% of max capacity), leaving a ~23% margin to absorb sudden spikes in load. + +--- + +## 3. Cold Start Strategy for New Containers + +| Strategy | Description | +|---|---| +| **Predictive Scaling** | Monitor the load growth trend and spin up new containers before the critical threshold is reached | +| **Warm Pool** | Keep 2-3 containers ready in standby mode so they can be activated instantly when needed | +| **Golden Images** | Use pre-baked, optimized container images to reduce init time | +| **Gradual Traffic Ramp-up** | Route traffic to the new container gradually instead of sending full load immediately | +| **Readiness Probe** | Don't add the container to the load balancer until it passes a readiness check | + +--- + +## 4. Using ghaymah Block Storage for Stateful Workloads + +- **Persistence across rescheduling:** If a container fails or is moved to another node, the same volume can be reattached without losing data. +- **Decoupling storage from compute:** Allows the container layer to scale independently from the storage layer. +- **Low-latency I/O:** Suitable for databases and queueing systems that need fast read/write. +- **Snapshots:** Periodic snapshots for backup and data recovery in case of failures. +- **Single-container attachment:** Typically used behind a centralized database rather than inside each of the 39 containers, to preserve statelessness in the processing layer. \ No newline at end of file diff --git a/q5-mithal-monitor/.dockerignore b/q5-mithal-monitor/.dockerignore new file mode 100644 index 0000000..af96311 --- /dev/null +++ b/q5-mithal-monitor/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +__pycache__ +*.pyc +*.pyo +*.pyd +.env +.dockerignore +Dockerfile +docker-compose.yml +README.md diff --git a/q5-mithal-monitor/Dockerfile b/q5-mithal-monitor/Dockerfile new file mode 100644 index 0000000..29df9cd --- /dev/null +++ b/q5-mithal-monitor/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies if required +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Install Python requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application files +COPY monitor.py index.html entrypoint.sh ./ +COPY data.jsonl data.csv monitoring_data.csv ./ + +# Create symlink so dashboard.html also works without code duplication +RUN ln -sf index.html dashboard.html && chmod +x entrypoint.sh + +# Expose HTTP dashboard port +EXPOSE 8080 + +# Run entrypoint script +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/q5-mithal-monitor/README.md b/q5-mithal-monitor/README.md new file mode 100644 index 0000000..822e65d --- /dev/null +++ b/q5-mithal-monitor/README.md @@ -0,0 +1,60 @@ +# مراقب موقع mithal.space (Mithal Website Monitor & Dashboard) + +تطبيق متكامل لمراقبة موقع `mithal.space` يجمع مقاييس الـ Uptime, Latency, SSL Certificate Expiry, DNS Resolution, و Search Latency بشكل مستمر ويعرضها على لوحة تحكم تفاعلية. + +--- + +## 🚀 التشغيل عبر Docker Compose (الافتراضي والمستحسن) + +قم بتشغيل الحاوية في الخلفية مع حفظ البيانات تلقائياً على الجهاز المستضيف: + +```bash +docker compose up -d +``` + +افتخ المتصفح وانتقل إلى: +👉 **[http://localhost:8080](http://localhost:8080)** + +لإيقاف الخدمة: +```bash +docker compose down +``` + +--- + +## 🐳 التشغيل المباشر عبر Docker (دون Compose) + +### 1. بناء صورة الـ Docker: +```bash +docker build -t mithal-monitor . +``` + +### 2. تشغيل الحاوية مع حفظ البيانات: +```bash +docker run -d \ + --name mithal-monitor-app \ + -p 8080:8080 \ + -v "$(pwd)/data.jsonl:/app/data.jsonl" \ + -v "$(pwd)/data.csv:/app/data.csv" \ + mithal-monitor +``` + +--- + +## 🛠 التشغيل المحلي بدون Docker + +### 1. تثبيت المتطلبات: +```bash +pip install -r requirements.txt +``` + +### 2. تشغيل المراقبة: +```bash +python monitor.py +``` + +### 3. تشغيل لوحة التحكم: +```bash +python -m http.server 8080 +``` +تصفح **[http://localhost:8080](http://localhost:8080)**. diff --git a/q5-mithal-monitor/data.csv b/q5-mithal-monitor/data.csv new file mode 100644 index 0000000..61f9035 --- /dev/null +++ b/q5-mithal-monitor/data.csv @@ -0,0 +1,24 @@ +timestamp,dns_time_ms,http_up,http_status_code,http_latency_ms,ssl_valid,ssl_days_remaining,ssl_expiry_date,search_up,search_status_code,search_latency_ms,error +2026-07-26T16:29:34.975347+00:00,7.38,True,200,1107.77,False,,,True,200,947.57,SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002) +2026-07-26T16:31:48.964707+00:00,24.95,True,200,1147.77,False,,,True,200,988.58,SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002) +2026-07-26T16:32:58.200316+00:00,8.97,True,200,6586.57,False,,,True,200,1298.52,SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002) +2026-07-26T16:34:00.920377+00:00,7.73,True,200,1153.76,False,,,True,200,1139.45,SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002) +2026-07-26T16:35:03.095644+00:00,8.06,True,200,1063.03,True,,,True,200,982.81,تعذّر قراءة تاريخ الانتهاء: ثبّت حزمة cryptography عبر: pip install cryptography +2026-07-26T16:36:06.934149+00:00,1.67,True,200,1306.19,True,,,True,200,1216.5,تعذّر قراءة تاريخ الانتهاء: ثبّت حزمة cryptography عبر: pip install cryptography +2026-07-26T16:37:10.770763+00:00,1.28,True,200,1358.48,True,,,True,200,1217.19,تعذّر قراءة تاريخ الانتهاء: ثبّت حزمة cryptography عبر: pip install cryptography +2026-07-26T16:40:13.186999+00:00,13.85,True,200,1177.05,True,50,2026-09-15,True,200,1001.08, +2026-07-26T16:40:35.147022+00:00,8.38,True,200,1001.0,True,50,2026-09-15,True,200,1051.0, +2026-07-26T16:41:37.737274+00:00,14.84,True,200,984.06,True,50,2026-09-15,True,200,1035.39, +2026-07-26T16:42:46.446648+00:00,1.15,True,200,1056.56,True,50,2026-09-15,True,200,2815.26, +2026-07-26T16:43:52.360311+00:00,1.09,True,200,2388.43,True,50,2026-09-15,True,200,1096.36, +2026-07-26T16:44:55.263746+00:00,0.97,True,200,945.49,True,50,2026-09-15,True,200,977.1, +2026-07-26T16:45:58.906118+00:00,0.91,True,200,978.28,True,50,2026-09-15,True,200,1013.73, +2026-07-26T16:47:05.160803+00:00,9.87,True,200,909.02,True,50,2026-09-15,True,200,917.93, +2026-07-26T16:51:32.327343+00:00,2.35,True,200,655.0,True,50,2026-09-15,True,200,658.37, +2026-07-26T16:52:34.256793+00:00,6.05,True,200,640.74,True,50,2026-09-15,True,200,631.15, +2026-07-26T16:53:39.970593+00:00,8.9,True,200,4007.5,True,50,2026-09-15,True,200,862.53, +2026-07-26T16:54:23.722444+00:00,6.02,True,200,1312.45,True,50,2026-09-15,True,200,1295.87, +2026-07-26T16:54:43.161904+00:00,7.25,True,200,1002.48,True,50,2026-09-15,True,200,840.82, +2026-07-26T16:55:26.868127+00:00,116.93,True,200,989.42,True,50,2026-09-15,True,200,989.09, +2026-07-26T16:55:45.089142+00:00,2.53,True,200,593.88,True,50,2026-09-15,True,200,675.95, +2026-07-26T16:56:48.649570+00:00,2.16,True,200,662.51,True,50,2026-09-15,True,200,2219.87, diff --git a/q5-mithal-monitor/data.jsonl b/q5-mithal-monitor/data.jsonl new file mode 100644 index 0000000..9e9113f --- /dev/null +++ b/q5-mithal-monitor/data.jsonl @@ -0,0 +1,23 @@ +{"timestamp": "2026-07-26T16:29:34.975347+00:00", "dns": {"time_ms": 7.38, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1107.77, "error": null}, "ssl": {"valid": false, "days_remaining": null, "expiry_date": null, "error": "SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002)"}, "search": {"up": true, "status_code": 200, "latency_ms": 947.57, "error": null}, "error": "SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002)"} +{"timestamp": "2026-07-26T16:31:48.964707+00:00", "dns": {"time_ms": 24.95, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1147.77, "error": null}, "ssl": {"valid": false, "days_remaining": null, "expiry_date": null, "error": "SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002)"}, "search": {"up": true, "status_code": 200, "latency_ms": 988.58, "error": null}, "error": "SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002)"} +{"timestamp": "2026-07-26T16:32:58.200316+00:00", "dns": {"time_ms": 8.97, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 6586.57, "error": null}, "ssl": {"valid": false, "days_remaining": null, "expiry_date": null, "error": "SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002)"}, "search": {"up": true, "status_code": 200, "latency_ms": 1298.52, "error": null}, "error": "SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002)"} +{"timestamp": "2026-07-26T16:34:00.920377+00:00", "dns": {"time_ms": 7.73, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1153.76, "error": null}, "ssl": {"valid": false, "days_remaining": null, "expiry_date": null, "error": "SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002)"}, "search": {"up": true, "status_code": 200, "latency_ms": 1139.45, "error": null}, "error": "SSL invalid: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1002)"} +{"timestamp": "2026-07-26T16:35:03.095644+00:00", "dns": {"time_ms": 8.06, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1063.03, "error": null}, "ssl": {"valid": true, "days_remaining": null, "expiry_date": null, "error": "تعذّر قراءة تاريخ الانتهاء: ثبّت حزمة cryptography عبر: pip install cryptography"}, "search": {"up": true, "status_code": 200, "latency_ms": 982.81, "error": null}, "error": "تعذّر قراءة تاريخ الانتهاء: ثبّت حزمة cryptography عبر: pip install cryptography"} +{"timestamp": "2026-07-26T16:36:06.934149+00:00", "dns": {"time_ms": 1.67, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1306.19, "error": null}, "ssl": {"valid": true, "days_remaining": null, "expiry_date": null, "error": "تعذّر قراءة تاريخ الانتهاء: ثبّت حزمة cryptography عبر: pip install cryptography"}, "search": {"up": true, "status_code": 200, "latency_ms": 1216.5, "error": null}, "error": "تعذّر قراءة تاريخ الانتهاء: ثبّت حزمة cryptography عبر: pip install cryptography"} +{"timestamp": "2026-07-26T16:37:10.770763+00:00", "dns": {"time_ms": 1.28, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1358.48, "error": null}, "ssl": {"valid": true, "days_remaining": null, "expiry_date": null, "error": "تعذّر قراءة تاريخ الانتهاء: ثبّت حزمة cryptography عبر: pip install cryptography"}, "search": {"up": true, "status_code": 200, "latency_ms": 1217.19, "error": null}, "error": "تعذّر قراءة تاريخ الانتهاء: ثبّت حزمة cryptography عبر: pip install cryptography"} +{"timestamp": "2026-07-26T16:40:13.186999+00:00", "dns": {"time_ms": 13.85, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1177.05, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 1001.08, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:40:35.147022+00:00", "dns": {"time_ms": 8.38, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1001.0, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 1051.0, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:41:37.737274+00:00", "dns": {"time_ms": 14.84, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 984.06, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 1035.39, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:42:46.446648+00:00", "dns": {"time_ms": 1.15, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1056.56, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 2815.26, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:43:52.360311+00:00", "dns": {"time_ms": 1.09, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 2388.43, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 1096.36, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:44:55.263746+00:00", "dns": {"time_ms": 0.97, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 945.49, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 977.1, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:45:58.906118+00:00", "dns": {"time_ms": 0.91, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 978.28, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 1013.73, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:47:05.160803+00:00", "dns": {"time_ms": 9.87, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 909.02, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 917.93, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:51:32.327343+00:00", "dns": {"time_ms": 2.35, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 655.0, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 658.37, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:52:34.256793+00:00", "dns": {"time_ms": 6.05, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 640.74, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 631.15, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:53:39.970593+00:00", "dns": {"time_ms": 8.9, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 4007.5, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 862.53, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:54:23.722444+00:00", "dns": {"time_ms": 6.02, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1312.45, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 1295.87, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:54:43.161904+00:00", "dns": {"time_ms": 7.25, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 1002.48, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 840.82, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:55:26.868127+00:00", "dns": {"time_ms": 116.93, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 989.42, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 989.09, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:55:45.089142+00:00", "dns": {"time_ms": 2.53, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 593.88, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 675.95, "error": null}, "error": null} +{"timestamp": "2026-07-26T16:56:48.649570+00:00", "dns": {"time_ms": 2.16, "error": null}, "http": {"up": true, "status_code": 200, "latency_ms": 662.51, "error": null}, "ssl": {"valid": true, "days_remaining": 50, "expiry_date": "2026-09-15", "error": null}, "search": {"up": true, "status_code": 200, "latency_ms": 2219.87, "error": null}, "error": null} diff --git a/q5-mithal-monitor/entrypoint.sh b/q5-mithal-monitor/entrypoint.sh new file mode 100644 index 0000000..20c8528 --- /dev/null +++ b/q5-mithal-monitor/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +export PYTHONUNBUFFERED=1 + +# Ensure dashboard.html points to index.html to avoid code duplication +if [ ! -L dashboard.html ] && [ ! -f dashboard.html ]; then + ln -sf index.html dashboard.html +fi + +echo "Starting Mithal website background monitor..." +python3 monitor.py & + +echo "Starting Mithal web dashboard on port 8080..." +exec python3 -m http.server 8080 diff --git a/q5-mithal-monitor/index.html b/q5-mithal-monitor/index.html new file mode 100644 index 0000000..0558af9 --- /dev/null +++ b/q5-mithal-monitor/index.html @@ -0,0 +1,385 @@ + + + + + +mithal.space — لوحة المراقبة + + + + + + + +
    +
    +
    +

    mithal.spaceلوحة مراقبة الموقع — Uptime · Latency · SSL · DNS · Search

    +
    +
    + لا توجد بيانات بعد + + +
    +
    + +
    +
    +
    + شغّل monitor.py لجمع البيانات، ثم اخدم هذا المجلد عبر خادم محلي + (مثال: python3 -m http.server 8080) ليتم تحميل data.jsonl تلقائياً، + أو استخدم زر «تحميل data.jsonl» أعلاه لاختيار الملف يدوياً. +
    +
    + + + + + + + + + + +
    + + + + diff --git a/q5-mithal-monitor/monitor.py b/q5-mithal-monitor/monitor.py new file mode 100644 index 0000000..d467b82 --- /dev/null +++ b/q5-mithal-monitor/monitor.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +""" +مراقب موقع mithal.space (Website Monitor) +========================================== +يجمع كل دقيقة: + - Latency : زمن استجابة HTTP للصفحة الرئيسية + - Uptime : هل الموقع متاح (status code 2xx/3xx) + - SSL : صلاحية الشهادة وعدد الأيام المتبقية على انتهائها + - DNS : زمن تحليل اسم النطاق (DNS resolution time) + - Search : زمن الرد على طلب بحث (search query) داخل الموقع + +يخزّن كل فحص كسطر JSON مستقل في data.jsonl (JSON Lines) وأيضاً كصف +في data.csv، بحيث يمكن قراءة الملف تراكمياً دون الحاجة لإعادة كتابته. + +الاستخدام: + python3 monitor.py # يعمل باستمرار، فحص كل 60 ثانية + python3 monitor.py --once # فحص واحد فقط (مناسب لجدولة cron) + python3 monitor.py --interval 30 # تغيير الفاصل الزمني (بالثواني) +""" + +import argparse +import concurrent.futures +import csv +import json +import os +import socket +import ssl +import sys +import time +from datetime import datetime, timezone + +import requests + +# ---------------------------------------------------------------------------- +# الإعدادات (Configuration) — عدّل هذه القيم حسب موقعك +# ---------------------------------------------------------------------------- +SITE_URL = "https://mithal.space" +SITE_HOST = "mithal.space" +SITE_PORT = 443 + +# رابط البحث الذي سيتم اختباره — عدّله ليطابق مسار البحث الفعلي في موقعك +# مثال: https://mithal.space/search?q=test +SEARCH_URL = "https://mithal.space/search?q=test" + +REQUEST_TIMEOUT = 10 # ثانية +CHECK_INTERVAL_SECONDS = 60 # كل دقيقة + +DATA_DIR = os.path.dirname(os.path.abspath(__file__)) +JSONL_PATH = os.path.join(DATA_DIR, "data.jsonl") +CSV_PATH = os.path.join(DATA_DIR, "data.csv") + +CSV_FIELDS = [ + "timestamp", + "dns_time_ms", + "http_up", + "http_status_code", + "http_latency_ms", + "ssl_valid", + "ssl_days_remaining", + "ssl_expiry_date", + "search_up", + "search_status_code", + "search_latency_ms", + "error", +] + + +def now_iso(): + return datetime.now(timezone.utc).isoformat() + + +# ---------------------------------------------------------------------------- +# 1) DNS — وقت تحليل اسم النطاق +# ---------------------------------------------------------------------------- +def measure_dns(hostname: str, timeout: float = REQUEST_TIMEOUT): + """ + ملاحظة مهمة: socket.getaddrinfo() لا يقبل معامل timeout أصلاً، وقد يتجمّد + إلى ما لا نهاية إذا كان الـ DNS بطيئاً أو محجوباً (VPN/جدار حماية/شبكة شركة). + لتفادي تجميد السكربت بالكامل، ننفّذ البحث في Thread منفصل ونفرض عليه + مهلة زمنية يدوياً عبر future.result(timeout=...). + """ + executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + start = time.perf_counter() + try: + future = executor.submit(socket.getaddrinfo, hostname, None) + future.result(timeout=timeout) + elapsed_ms = (time.perf_counter() - start) * 1000 + return {"time_ms": round(elapsed_ms, 2), "error": None} + except concurrent.futures.TimeoutError: + return {"time_ms": None, "error": f"DNS timeout after {timeout}s (تحقق من الاتصال بالإنترنت أو VPN/DNS)"} + except socket.gaierror as e: + return {"time_ms": None, "error": str(e)} + finally: + # wait=False حتى لا ننتظر انتهاء الـ thread العالق في حال حدوث timeout + executor.shutdown(wait=False) + + +# ---------------------------------------------------------------------------- +# 2) HTTP — Latency + Uptime (status code) +# ---------------------------------------------------------------------------- +def measure_http(url: str): + try: + start = time.perf_counter() + resp = requests.get(url, timeout=REQUEST_TIMEOUT) + elapsed_ms = (time.perf_counter() - start) * 1000 + return { + "up": resp.status_code < 400, + "status_code": resp.status_code, + "latency_ms": round(elapsed_ms, 2), + "error": None, + } + except requests.exceptions.RequestException as e: + return {"up": False, "status_code": None, "latency_ms": None, "error": str(e)} + + +# ---------------------------------------------------------------------------- +# 3) SSL — حالة الشهادة وتاريخ الانتهاء +# ---------------------------------------------------------------------------- +def _get_ca_context(): + """يستخدم حزمة CA من certifi إن كانت مثبتة (أدق وأكثر استقراراً على ويندوز + من الاعتماد على مخزن الشهادات الافتراضي)، وإلا يعود للسياق الافتراضي.""" + try: + import certifi + return ssl.create_default_context(cafile=certifi.where()) + except ImportError: + return ssl.create_default_context() + + +def _read_cert_expiry(der_bytes: bytes): + """Read certificate expiry date using Python's built-in SSL module.""" + try: + pem_bytes = ssl.DER_cert_to_PEM_cert(der_bytes) + + # ssl._ssl._test_decode_cert() needs a file, + # so we create a temporary certificate file. + import tempfile + + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".pem", + delete=False + ) as f: + f.write(pem_bytes) + cert_path = f.name + + try: + cert_info = ssl._ssl._test_decode_cert(cert_path) + expiry_str = cert_info.get("notAfter") + + if expiry_str: + expiry_dt = datetime.strptime( + expiry_str, + "%b %d %H:%M:%S %Y %Z" + ).replace(tzinfo=timezone.utc) + + return expiry_dt + + return None + + finally: + os.unlink(cert_path) + + except Exception: + return None + + +def check_ssl(hostname: str, port: int = 443): + """ + يفصل بين أمرين مختلفين قد يُخلط بينهما: + 1) trusted : هل تثق سلسلة الشهادات (chain of trust) بالشهادة عبر حزمة + CA؟ قد تفشل هذه الخطوة محلياً (خصوصاً على ويندوز) دون أن + يكون هناك أي خلل في شهادة الموقع نفسه. + 2) expiry : تاريخ انتهاء الشهادة الفعلي — نقرأه دائماً من البايتات + الخام للشهادة (getpeercert(binary_form=True)) بغض النظر + عن نجاح التحقق من الثقة، لأن getpeercert() العادي يعيد + قاموساً فارغاً {} إن لم يتم التحقق من الثقة. + """ + trusted = False + trust_error = None + days_remaining = None + expiry_date = None + + # 1) محاولة اتصال بالتحقق الكامل من الثقة + try: + context = _get_ca_context() + with socket.create_connection((hostname, port), timeout=REQUEST_TIMEOUT) as sock: + with context.wrap_socket(sock, server_hostname=hostname): + trusted = True + except ssl.SSLCertVerificationError as e: + trust_error = f"فشل التحقق من سلسلة الثقة محلياً (قد يكون خلل CA bundle وليس عيباً بالشهادة): {e}" + except (socket.timeout, socket.gaierror, ConnectionRefusedError, OSError) as e: + return {"valid": False, "days_remaining": None, "expiry_date": None, "error": str(e)} + + # 2) قراءة تاريخ الانتهاء بشكل مستقل، حتى لو فشلت خطوة الثقة أعلاه + try: + unverified_context = ssl._create_unverified_context() + with socket.create_connection((hostname, port), timeout=REQUEST_TIMEOUT) as sock: + with unverified_context.wrap_socket(sock, server_hostname=hostname) as ssock: + der_bytes = ssock.getpeercert(binary_form=True) + expiry_dt = _read_cert_expiry(der_bytes) if der_bytes else None + if expiry_dt is not None: + days_remaining = (expiry_dt - datetime.now(timezone.utc)).days + expiry_date = expiry_dt.date().isoformat() + elif trust_error is None: + trust_error = "تعذّر قراءة تاريخ الانتهاء: ثبّت حزمة cryptography عبر: pip install cryptography" + except (socket.timeout, socket.gaierror, ConnectionRefusedError, OSError) as e: + if trust_error is None: + trust_error = str(e) + + return { + "valid": trusted, + "days_remaining": days_remaining, + "expiry_date": expiry_date, + "error": trust_error, + } + + +# ---------------------------------------------------------------------------- +# 4) Search Response — زمن الرد على طلب بحث +# ---------------------------------------------------------------------------- +def measure_search(url: str): + try: + start = time.perf_counter() + resp = requests.get(url, timeout=REQUEST_TIMEOUT) + elapsed_ms = (time.perf_counter() - start) * 1000 + return { + "up": resp.status_code < 400, + "status_code": resp.status_code, + "latency_ms": round(elapsed_ms, 2), + "error": None, + } + except requests.exceptions.RequestException as e: + return {"up": False, "status_code": None, "latency_ms": None, "error": str(e)} + + +# ---------------------------------------------------------------------------- +# تجميع كل المقاييس في فحص واحد +# ---------------------------------------------------------------------------- +def collect_metrics(): + dns = measure_dns(SITE_HOST) + http = measure_http(SITE_URL) + ssl_info = check_ssl(SITE_HOST, SITE_PORT) + search = measure_search(SEARCH_URL) + + errors = [e for e in (dns.get("error"), http.get("error"), ssl_info.get("error"), search.get("error")) if e] + + return { + "timestamp": now_iso(), + "dns": dns, + "http": http, + "ssl": ssl_info, + "search": search, + "error": "; ".join(errors) if errors else None, + } + + +# ---------------------------------------------------------------------------- +# التخزين: JSON Lines + CSV +# ---------------------------------------------------------------------------- +def save_jsonl(record: dict): + with open(JSONL_PATH, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def save_csv(record: dict): + file_exists = os.path.isfile(CSV_PATH) + row = { + "timestamp": record["timestamp"], + "dns_time_ms": record["dns"].get("time_ms"), + "http_up": record["http"].get("up"), + "http_status_code": record["http"].get("status_code"), + "http_latency_ms": record["http"].get("latency_ms"), + "ssl_valid": record["ssl"].get("valid"), + "ssl_days_remaining": record["ssl"].get("days_remaining"), + "ssl_expiry_date": record["ssl"].get("expiry_date"), + "search_up": record["search"].get("up"), + "search_status_code": record["search"].get("status_code"), + "search_latency_ms": record["search"].get("latency_ms"), + "error": record.get("error"), + } + with open(CSV_PATH, "a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=CSV_FIELDS) + if not file_exists: + writer.writeheader() + writer.writerow(row) + + +def run_once(): + record = collect_metrics() + save_jsonl(record) + save_csv(record) + status = "UP" if record["http"]["up"] else "DOWN" + print( + f"[{record['timestamp']}] HTTP={status} " + f"latency={record['http']['latency_ms']}ms " + f"dns={record['dns']['time_ms']}ms " + f"ssl_days_left={record['ssl']['days_remaining']} " + f"search={record['search']['latency_ms']}ms", + flush=True, + ) + return record + + +def main(): + parser = argparse.ArgumentParser(description="مراقب موقع mithal.space") + parser.add_argument("--once", action="store_true", help="فحص واحد فقط ثم الخروج (مناسب لـ cron)") + parser.add_argument( + "--interval", type=int, default=CHECK_INTERVAL_SECONDS, help="الفاصل الزمني بالثواني بين الفحوصات" + ) + args = parser.parse_args() + + if args.once: + run_once() + return + + print(f"بدء المراقبة المستمرة لـ {SITE_URL} كل {args.interval} ثانية... (Ctrl+C للإيقاف)", flush=True) + while True: + try: + run_once() + except Exception as e: + # لا نسمح لأي خطأ غير متوقع بإيقاف الحلقة الرئيسية + print(f"خطأ غير متوقع أثناء الفحص: {e}", flush=True) + time.sleep(args.interval) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/q5-mithal-monitor/monitoring_data.csv b/q5-mithal-monitor/monitoring_data.csv new file mode 100644 index 0000000..ea0adc1 --- /dev/null +++ b/q5-mithal-monitor/monitoring_data.csv @@ -0,0 +1,2 @@ +timestamp,status_code,latency_ms,uptime,ssl_valid,ssl_expiry,ssl_days_remaining,ssl_check_latency_ms,dns_success,dns_latency_ms,search_status_code,search_latency_ms,search_success +2026-07-26T16:24:48.432979+00:00,200,1240.09,True,False,,,,True,0.6,200,1003.56,True diff --git a/q5-mithal-monitor/requirements.txt b/q5-mithal-monitor/requirements.txt new file mode 100644 index 0000000..e61df18 --- /dev/null +++ b/q5-mithal-monitor/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.28.0 +certifi>=2023.0.0 +cryptography>=40.0.0