diff --git a/.dockerignore b/.dockerignore index 70c95f6..2729971 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,5 @@ Jenkinsfile README.md .env* *.md +data/ +*.db diff --git a/Dockerfile b/Dockerfile index f9f8469..ba96581 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,17 @@ -# Simple example app - a minimal Python web server +# Statping clone - HTTP/HTTPS and TCP monitoring FROM python:3.11-slim WORKDIR /app +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + # Copy application COPY app.py . +COPY app/ app/ +COPY templates/ templates/ +COPY static/ static/ EXPOSE 8080 diff --git a/README.md b/README.md index eb9c6fa..5846b91 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Jenkins Docker Deploy Example -Example repository demonstrating a Jenkins pipeline that: +A Statping-like status monitoring app that demonstrates a Jenkins pipeline for Docker build, push, and deploy. The app performs HTTP/HTTPS and TCP checks, stores history in SQLite, and provides a dashboard with reports. -1. **Builds** a Docker image -2. **Pushes** the image to a container registry (Docker Hub, etc.) -3. **SSHs** to a deployment machine -4. **Clones** (or pulls) this repo to get `docker-compose.yml` -5. **Deploys** with `docker compose up -d` +## App Features + +- **HTTP/HTTPS checks** – Ping URLs, measure response time and status code (success = 2xx) +- **TCP checks** – Verify connectivity to host:port +- **History storage** – SQLite database persists check results +- **Reports** – Uptime %, avg/min/max latency, recent check history ## Repository Structure @@ -15,77 +16,86 @@ Example repository demonstrating a Jenkins pipeline that: ├── Jenkinsfile # Pipeline definition ├── Dockerfile # Application image ├── docker-compose.yml # Deployment compose (pulled by deploy host) -├── app.py # Minimal Python web app +├── app.py # Entry point +├── app/ +│ ├── main.py # Flask routes +│ ├── models.py # SQLite schema +│ ├── checker.py # HTTP/HTTPS and TCP check logic +│ └── scheduler.py # APScheduler for periodic checks +├── templates/ # HTML templates +├── static/ # CSS +├── requirements.txt └── README.md ``` -## Prerequisites - -### Jenkins - -- **Docker** installed and Jenkins user in `docker` group -- **Pipeline** and **SSH Agent** plugins -- **Git** for cloning - -### Jenkins Credentials - -Create these in **Manage Jenkins → Credentials**: - -| ID | Type | Purpose | -|----|------|---------| -| `docker-registry-credentials` | Username/Password | Docker Hub or registry login | -| `deploy-ssh-key` | SSH Username with private key | SSH to deploy host | - -### Deploy Host - -- Docker and Docker Compose installed -- SSH access for the deploy user -- If using a **private registry**: run `docker login` on the deploy host (or add to the pipeline) - -## Configuration - -Edit the `environment` block in `Jenkinsfile`: - -```groovy -environment { - DOCKER_REGISTRY = 'docker.io' // or your registry (ghcr.io, etc.) - DOCKER_IMAGE = 'myorg/myapp' // e.g., username/repo - DEPLOY_HOST = 'deploy-server.example.com' - DEPLOY_USER = 'deploy' - DEPLOY_PATH = '/opt/myapp' // Where to clone & run compose - GIT_REPO_URL = 'https://github.com/myorg/jenkins-docker-deploy-example.git' -} -``` - -## Pipeline Stages - -1. **Build** – `docker build` with tag from branch (e.g. `latest` for main, `123-abc1234` for others) -2. **Push** – `docker push` to registry using stored credentials -3. **Deploy** – SSH to host, clone/pull repo, run `docker compose up -d` - -## First-Time Deploy Host Setup - -On the deploy host: - -```bash -# Create deploy directory -sudo mkdir -p /opt/myapp -sudo chown deploy:deploy /opt/myapp - -# Ensure deploy user can run docker (add to docker group) -sudo usermod -aG docker deploy -``` - ## Manual Test ```bash # Build and run locally docker build -t myapp:test . -docker run -p 8080:8080 myapp:test +docker run -p 8080:8080 -v $(pwd)/data:/app/data myapp:test # Visit http://localhost:8080 ``` -## Branch Behavior +Add services from the dashboard (e.g. `https://example.com`, `google.com:443` for TCP) and view reports. + +## Jenkins Pipeline + +The pipeline: + +1. **Builds** a Docker image +2. **Pushes** the image to a container registry (Docker Hub, etc.) +3. **SSHs** to a deployment machine +4. **Clones** (or pulls) this repo to get `docker-compose.yml` +5. **Deploys** with `docker compose up -d` + +### Prerequisites + +**Jenkins** + +- Docker installed and Jenkins user in `docker` group +- Pipeline and SSH Agent plugins +- Git for cloning + +**Jenkins Credentials** + +| ID | Type | Purpose | +|----|------|---------| +| `docker-registry-credentials` | Username/Password | Docker Hub or registry login | +| `deploy-ssh-key` | SSH Username with private key | SSH to deploy host | + +**Deploy Host** + +- Docker and Docker Compose installed +- SSH access for the deploy user +- If using a private registry: run `docker login` on the deploy host + +### Configuration + +Edit the `environment` block in `Jenkinsfile`: + +```groovy +environment { + DOCKER_REGISTRY = 'docker.io' + DOCKER_IMAGE = 'myorg/myapp' + DEPLOY_HOST = 'deploy-server.example.com' + DEPLOY_USER = 'deploy' + DEPLOY_PATH = '/opt/myapp' + GIT_REPO_URL = 'https://github.com/myorg/jenkins-docker-deploy-example.git' +} +``` + +### First-Time Deploy Host Setup + +```bash +sudo mkdir -p /opt/myapp +sudo chown deploy:deploy /opt/myapp +sudo usermod -aG docker deploy +``` + +The `docker-compose.yml` mounts `./data:/app/data` for SQLite persistence. Ensure the deploy directory is writable. + +### Branch Behavior - **main** → image tag `latest` - **other branches** → image tag `{BUILD_NUMBER}-{GIT_SHA}` diff --git a/app.py b/app.py index 659f7e9..51e3bae 100644 --- a/app.py +++ b/app.py @@ -1,26 +1,13 @@ #!/usr/bin/env python3 -"""Minimal web app for deployment example.""" +"""Entry point for Statping clone - HTTP/HTTPS and TCP monitoring.""" import os -from http.server import HTTPServer, BaseHTTPRequestHandler - -PORT = int(os.environ.get("PORT", 8080)) -VERSION = os.environ.get("VERSION", "dev") - - -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write( - f"

Hello from Docker!

Version: {VERSION}

".encode() - ) - - def log_message(self, format, *args): - print(f"[{self.log_date_time_string()}] {format % args}") +from app.main import app +from app.models import init_db +from app.scheduler import start_scheduler if __name__ == "__main__": - server = HTTPServer(("", PORT), Handler) - print(f"Server running on port {PORT}") - server.serve_forever() + init_db() + start_scheduler() + port = int(os.environ.get("PORT", 8080)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ce64809 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Statping clone - HTTP/HTTPS and TCP monitoring with history and reports.""" diff --git a/app/checker.py b/app/checker.py new file mode 100644 index 0000000..fc3be14 --- /dev/null +++ b/app/checker.py @@ -0,0 +1,64 @@ +"""HTTP/HTTPS and TCP check logic.""" +import socket +import time +from urllib.parse import urlparse + +import requests + +TIMEOUT = 10 + + +def check_http(url: str) -> tuple[bool, float | None, str | None]: + """ + Check HTTP/HTTPS endpoint. Returns (success, response_time_ms, error_message). + Success = 2xx status code. + """ + if not url.startswith(("http://", "https://")): + url = "https://" + url + try: + start = time.perf_counter() + r = requests.get(url, timeout=TIMEOUT) + elapsed_ms = (time.perf_counter() - start) * 1000 + success = 200 <= r.status_code < 300 + return success, elapsed_ms, None if success else f"HTTP {r.status_code}" + except requests.RequestException as e: + return False, None, str(e) + + +def check_tcp(host: str, port: int) -> tuple[bool, float | None, str | None]: + """ + Check TCP connectivity. Returns (success, response_time_ms, error_message). + """ + try: + start = time.perf_counter() + sock = socket.create_connection((host, port), timeout=TIMEOUT) + sock.close() + elapsed_ms = (time.perf_counter() - start) * 1000 + return True, elapsed_ms, None + except (socket.error, OSError) as e: + return False, None, str(e) + + +def run_check(service_id: int, target: str, protocol: str): + """Run a single check and store the result.""" + from app.models import add_check + + protocol = protocol.lower() + if protocol in ("http", "https"): + success, response_time_ms, error_message = check_http(target) + elif protocol == "tcp": + parts = target.split(":") + if len(parts) != 2: + add_check(service_id, False, None, "Invalid target: expected host:port") + return + try: + port = int(parts[1]) + except ValueError: + add_check(service_id, False, None, "Invalid port") + return + success, response_time_ms, error_message = check_tcp(parts[0].strip(), port) + else: + add_check(service_id, False, None, f"Unknown protocol: {protocol}") + return + + add_check(service_id, success, response_time_ms, error_message) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3032080 --- /dev/null +++ b/app/main.py @@ -0,0 +1,203 @@ +"""Flask app and routes.""" +import os +from datetime import datetime, timedelta, timezone + +from flask import Flask, redirect, render_template, request, url_for + +from app import models + + +def _parse_report_dates(from_ts, to_ts, preset): + """Parse from/to dates, applying preset if given. Returns (from_ts, to_ts, from_display, to_display).""" + now = datetime.now(timezone.utc) + if preset == "24h": + to_ts = now.isoformat() + from_ts = (now - timedelta(hours=24)).isoformat() + elif preset == "7d": + to_ts = now.isoformat() + from_ts = (now - timedelta(days=7)).isoformat() + elif preset == "30d": + to_ts = now.isoformat() + from_ts = (now - timedelta(days=30)).isoformat() + if from_ts and len(from_ts) == 10: + from_ts = from_ts + "T00:00:00" + if to_ts and len(to_ts) == 10: + to_ts = to_ts + "T23:59:59.999999" + from_display = from_ts[:16] if from_ts else "" + to_display = to_ts[:16] if to_ts else "" + return from_ts, to_ts, from_display, to_display + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +app = Flask( + __name__, + template_folder=os.path.join(ROOT, "templates"), + static_folder=os.path.join(ROOT, "static"), +) +VERSION = os.environ.get("VERSION", "dev") + + +@app.route("/") +def dashboard(): + services = models.list_services() + return render_template("dashboard.html", services=services, version=VERSION) + + +@app.route("/api/services", methods=["GET"]) +def api_list_services(): + services = models.list_services() + return {"services": services} + + +@app.route("/api/services", methods=["POST"]) +def api_add_service(): + data = request.get_json(silent=True) + if data is None and request.form: + data = request.form.to_dict() + if not data: + return {"error": "Request body must be valid JSON (Content-Type: application/json) or form data"}, 415 + name = data.get("name", "").strip() + target = data.get("target", "").strip() + protocol = (data.get("protocol") or "https").lower().strip() + if not name or not target: + return {"error": "name and target are required"}, 400 + if protocol not in ("http", "https", "tcp"): + return {"error": "protocol must be http, https, or tcp"}, 400 + interval = int(data.get("interval_seconds", 60)) + interval = max(10, min(3600, interval)) + try: + sid = models.add_service(name, target, protocol, interval) + if request.accept_mimetypes.best == "application/json" or request.is_json: + return {"id": sid, "name": name, "target": target, "protocol": protocol} + return redirect(url_for("dashboard")) + except Exception as e: + return {"error": str(e)}, 500 + + +@app.route("/api/services/", methods=["DELETE"]) +def api_delete_service(service_id): + if models.delete_service(service_id): + return {"deleted": service_id} + return {"error": "Not found"}, 404 + + +@app.route("/api/services/", methods=["PATCH"]) +def api_update_service(service_id): + svc = models.get_service(service_id) + if not svc: + return {"error": "Not found"}, 404 + data = request.get_json(silent=True) + if data is None and request.form: + data = request.form.to_dict() + if not data: + return {"error": "Request body must be valid JSON or form data"}, 415 + updates = {} + if "name" in data and data["name"] is not None: + updates["name"] = str(data["name"]).strip() + if "target" in data and data["target"] is not None: + updates["target"] = str(data["target"]).strip() + if "protocol" in data and data["protocol"] is not None: + p = str(data["protocol"]).lower().strip() + if p not in ("http", "https", "tcp"): + return {"error": "protocol must be http, https, or tcp"}, 400 + updates["protocol"] = p + if "interval_seconds" in data and data["interval_seconds"] is not None: + updates["interval_seconds"] = max(10, min(3600, int(data["interval_seconds"]))) + if not updates: + return {"id": service_id, **dict(svc)} + if updates.get("name") == "" or updates.get("target") == "": + return {"error": "name and target cannot be empty"}, 400 + if models.update_service(service_id, **updates): + updated = models.get_service(service_id) + return dict(updated) + return {"error": "Update failed"}, 500 + + +@app.route("/api/services/") +def api_get_service(service_id): + svc = models.get_service(service_id) + if not svc: + return {"error": "Not found"}, 404 + checks = models.get_checks(service_id, limit=50) + return {"service": dict(svc), "checks": checks} + + +@app.route("/api/services//edit") +def edit_service(service_id): + svc = models.get_service(service_id) + if not svc: + return "Service not found", 404 + return render_template("edit.html", service=dict(svc), version=VERSION) + + +@app.route("/api/services//report") +def report(service_id): + svc = models.get_service(service_id) + if not svc: + return "Service not found", 404 + from_ts = request.args.get("from") or None + to_ts = request.args.get("to") or None + preset = request.args.get("preset") + from_ts, to_ts, from_display, to_display = _parse_report_dates(from_ts, to_ts, preset) + status_filter = request.args.get("status") + search = request.args.get("search", "").strip() or None + stats = models.get_report_stats(service_id, from_ts=from_ts, to_ts=to_ts) + checks = models.get_checks(service_id, limit=100, from_ts=from_ts, to_ts=to_ts, status_filter=status_filter, search=search) + chart_checks = models.get_checks(service_id, limit=200, from_ts=from_ts, to_ts=to_ts) + period_label = _format_period_label(from_display, to_display) if (from_ts or to_ts) else None + return render_template( + "report.html", + service=dict(svc), + stats=stats, + checks=checks, + chart_checks=chart_checks, + version=VERSION, + from_date=from_display, + to_date=to_display, + period_label=period_label, + preset=preset, + status_filter=status_filter or "", + search=search or "", + ) + + +def _format_period_label(from_display, to_display): + """Format period for display.""" + if from_display and to_display: + return f"{from_display} to {to_display}" + if from_display: + return f"From {from_display}" + if to_display: + return f"Until {to_display}" + return None + + +@app.route("/api/services//history") +def api_history(service_id): + svc = models.get_service(service_id) + if not svc: + return {"error": "Not found"}, 404 + limit = min(500, int(request.args.get("limit", 100))) + from_ts = request.args.get("from") or None + to_ts = request.args.get("to") or None + if from_ts and len(from_ts) == 10: + from_ts = from_ts + "T00:00:00" + if to_ts and len(to_ts) == 10: + to_ts = to_ts + "T23:59:59.999999" + history = models.get_history(service_id, limit=limit, from_ts=from_ts, to_ts=to_ts) + return {"history": history} + + +@app.route("/api/services//stats") +def api_report_stats(service_id): + """JSON report stats with optional from/to query params for date range.""" + svc = models.get_service(service_id) + if not svc: + return {"error": "Not found"}, 404 + from_ts = request.args.get("from") or None + to_ts = request.args.get("to") or None + if from_ts and len(from_ts) == 10: + from_ts = from_ts + "T00:00:00" + if to_ts and len(to_ts) == 10: + to_ts = to_ts + "T23:59:59.999999" + stats = models.get_report_stats(service_id, from_ts=from_ts, to_ts=to_ts) + return {"service": dict(svc), "stats": stats} diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..967e0e7 --- /dev/null +++ b/app/models.py @@ -0,0 +1,221 @@ +"""SQLite schema and database helpers.""" +import os +import sqlite3 +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path + +DATA_PATH = os.environ.get("DATA_PATH", "/app/data") +DB_PATH = Path(DATA_PATH) / "monitor.db" + + +def _ensure_data_dir(): + Path(DATA_PATH).mkdir(parents=True, exist_ok=True) + + +def _migrate_add_status(conn): + """Add status column if missing (migration for existing DBs).""" + try: + conn.execute("SELECT status FROM checks LIMIT 1") + except sqlite3.OperationalError: + conn.execute("ALTER TABLE checks ADD COLUMN status TEXT") + conn.execute("UPDATE checks SET status = CASE WHEN success = 1 THEN 'OK' ELSE 'ERROR' END") + + +@contextmanager +def get_db(): + _ensure_data_dir() + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + + +def init_db(): + """Create tables if they don't exist.""" + with get_db() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + target TEXT NOT NULL, + protocol TEXT NOT NULL CHECK(protocol IN ('http', 'https', 'tcp')), + interval_seconds INTEGER NOT NULL DEFAULT 60, + created_at TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + success INTEGER NOT NULL, + status TEXT NOT NULL, + response_time_ms REAL, + timestamp TEXT NOT NULL, + error_message TEXT, + FOREIGN KEY (service_id) REFERENCES services(id) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_checks_service ON checks(service_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_checks_timestamp ON checks(timestamp)") + _migrate_add_status(conn) + conn.execute("CREATE INDEX IF NOT EXISTS idx_checks_status ON checks(status)") + + +def list_services(): + """Return all services with last check info and uptime.""" + with get_db() as conn: + rows = conn.execute( + """ + SELECT s.*, + (SELECT success FROM checks WHERE service_id = s.id ORDER BY timestamp DESC LIMIT 1) as last_success, + (SELECT response_time_ms FROM checks WHERE service_id = s.id ORDER BY timestamp DESC LIMIT 1) as last_response_ms, + (SELECT timestamp FROM checks WHERE service_id = s.id ORDER BY timestamp DESC LIMIT 1) as last_check, + (SELECT ROUND(100.0 * SUM(success) / NULLIF(COUNT(*), 0), 2) + FROM (SELECT success FROM checks WHERE service_id = s.id ORDER BY timestamp DESC LIMIT 1000)) as uptime_pct + FROM services s + ORDER BY s.id + """ + ).fetchall() + return [dict(r) for r in rows] + + +def get_service(service_id: int): + """Get a single service by id.""" + with get_db() as conn: + row = conn.execute("SELECT * FROM services WHERE id = ?", (service_id,)).fetchone() + return dict(row) if row else None + + +def add_service(name: str, target: str, protocol: str, interval_seconds: int = 60) -> int: + """Add a new service. Returns the new service id.""" + with get_db() as conn: + cur = conn.execute( + "INSERT INTO services (name, target, protocol, interval_seconds, created_at) VALUES (?, ?, ?, ?, ?)", + (name, target, protocol, interval_seconds, datetime.utcnow().isoformat()), + ) + return cur.lastrowid + + +def update_service(service_id: int, name: str = None, target: str = None, protocol: str = None, interval_seconds: int = None) -> bool: + """Update a service. Only provided fields are updated. Returns True if updated.""" + updates = [] + args = [] + if name is not None: + updates.append("name = ?") + args.append(name) + if target is not None: + updates.append("target = ?") + args.append(target) + if protocol is not None: + updates.append("protocol = ?") + args.append(protocol) + if interval_seconds is not None: + updates.append("interval_seconds = ?") + args.append(interval_seconds) + if not updates: + return True + args.append(service_id) + with get_db() as conn: + cur = conn.execute( + f"UPDATE services SET {', '.join(updates)} WHERE id = ?", + args, + ) + return cur.rowcount > 0 + + +def add_check(service_id: int, success: bool, response_time_ms: float | None, error_message: str | None = None): + """Record a check result. status is OK or ERROR for searchability.""" + status = "OK" if success else "ERROR" + with get_db() as conn: + conn.execute( + "INSERT INTO checks (service_id, success, status, response_time_ms, timestamp, error_message) VALUES (?, ?, ?, ?, ?, ?)", + (service_id, 1 if success else 0, status, response_time_ms, datetime.utcnow().isoformat(), error_message), + ) + + +def get_checks(service_id: int, limit: int = 50, from_ts: str = None, to_ts: str = None, status_filter: str = None, search: str = None): + """Get recent checks for a service, optionally filtered by timestamp, status (ok/error), and error search.""" + with get_db() as conn: + q = "SELECT * FROM checks WHERE service_id = ?" + args = [service_id] + if from_ts: + q += " AND timestamp >= ?" + args.append(from_ts) + if to_ts: + q += " AND timestamp <= ?" + args.append(to_ts) + if status_filter == "error": + q += " AND status = 'ERROR'" + elif status_filter == "ok": + q += " AND status = 'OK'" + if search: + q += " AND (error_message LIKE ? OR status LIKE ?)" + args.extend([f"%{search}%", f"%{search}%"]) + q += " ORDER BY timestamp DESC LIMIT ?" + args.append(limit) + rows = conn.execute(q, args).fetchall() + return [dict(r) for r in rows] + + +def get_report_stats(service_id: int, from_ts: str = None, to_ts: str = None): + """Compute uptime % and latency stats for a service, optionally over a time range.""" + with get_db() as conn: + q = "SELECT success, response_time_ms FROM checks WHERE service_id = ?" + args = [service_id] + if from_ts: + q += " AND timestamp >= ?" + args.append(from_ts) + if to_ts: + q += " AND timestamp <= ?" + args.append(to_ts) + q += " ORDER BY timestamp DESC LIMIT 10000" + rows = conn.execute(q, args).fetchall() + if not rows: + return {"total": 0, "uptime_pct": 0, "avg_ms": None, "min_ms": None, "max_ms": None} + total = len(rows) + success_count = sum(1 for r in rows if r["success"]) + uptime_pct = (success_count / total) * 100 if total else 0 + response_times = [r["response_time_ms"] for r in rows if r["response_time_ms"] is not None] + return { + "total": total, + "uptime_pct": round(uptime_pct, 2), + "avg_ms": round(sum(response_times) / len(response_times), 2) if response_times else None, + "min_ms": min(response_times) if response_times else None, + "max_ms": max(response_times) if response_times else None, + } + + +def get_history(service_id: int, limit: int = 100, from_ts: str = None, to_ts: str = None): + """Get check history for charts (JSON), optionally filtered by timestamp range.""" + with get_db() as conn: + q = "SELECT timestamp, success, response_time_ms FROM checks WHERE service_id = ?" + args = [service_id] + if from_ts: + q += " AND timestamp >= ?" + args.append(from_ts) + if to_ts: + q += " AND timestamp <= ?" + args.append(to_ts) + q += " ORDER BY timestamp DESC LIMIT ?" + args.append(limit) + rows = conn.execute(q, args).fetchall() + return [{"timestamp": r["timestamp"], "success": bool(r["success"]), "response_time_ms": r["response_time_ms"]} for r in rows] + + +def delete_service(service_id: int) -> bool: + """Delete a service and its check history. Returns True if deleted.""" + with get_db() as conn: + conn.execute("DELETE FROM checks WHERE service_id = ?", (service_id,)) + cur = conn.execute("DELETE FROM services WHERE id = ?", (service_id,)) + return cur.rowcount > 0 + + +def get_all_services_for_scheduler(): + """Return all services for the scheduler.""" + with get_db() as conn: + rows = conn.execute("SELECT id, target, protocol, interval_seconds FROM services").fetchall() + return [dict(r) for r in rows] diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..12ae4f0 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,41 @@ +"""APScheduler setup for periodic checks.""" +from apscheduler.schedulers.background import BackgroundScheduler + +from app.checker import run_check +from app.models import get_all_services_for_scheduler + + +def _run_all_checks(): + """Run checks for all registered services.""" + services = get_all_services_for_scheduler() + for svc in services: + run_check(svc["id"], svc["target"], svc["protocol"]) + + +def start_scheduler(): + """Start the background scheduler. Uses interval jobs per service.""" + scheduler = BackgroundScheduler() + + def add_jobs(): + services = get_all_services_for_scheduler() + for svc in services: + job_id = f"service_{svc['id']}" + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) + interval = max(10, svc["interval_seconds"]) + scheduler.add_job( + run_check, + "interval", + seconds=interval, + id=job_id, + args=[svc["id"], svc["target"], svc["protocol"]], + ) + + # Run checks immediately on startup, then schedule + _run_all_checks() + add_jobs() + + # Refresh job list every 60 seconds in case services were added + scheduler.add_job(add_jobs, "interval", seconds=60, id="refresh_jobs") + + scheduler.start() diff --git a/data/monitor.db b/data/monitor.db new file mode 100644 index 0000000..6d38d5b Binary files /dev/null and b/data/monitor.db differ diff --git a/docker-compose.yml b/docker-compose.yml index ea1c397..97d45f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: container_name: jenkins-deploy-app ports: - "8080:8080" + volumes: + - ./data:/app/data environment: - VERSION=${IMAGE_TAG:-latest} restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..81a76be --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +requests>=2.31 +apscheduler>=3.10 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..d78a86e --- /dev/null +++ b/static/style.css @@ -0,0 +1,369 @@ +:root { + --bg: #1a1a2e; + --surface: #16213e; + --text: #eaeaea; + --muted: #94a3b8; + --up: #22c55e; + --down: #ef4444; + --pending: #eab308; + --accent: #3b82f6; +} + +* { + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + margin: 0; + padding: 1rem 2rem; + line-height: 1.5; + display: flex; + flex-direction: column; + align-items: center; +} + +header { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--surface); + padding-bottom: 0.5rem; + width: 100%; + max-width: 900px; +} + +header h1 { + margin: 0; + font-size: 1.5rem; +} + +header h1 a { + color: var(--text); + text-decoration: none; +} + +header h1 a:hover { + color: var(--accent); +} + +.version { + color: var(--muted); + font-size: 0.875rem; +} + +main { + max-width: 900px; + width: 100%; +} + +h2 { + margin-top: 0; + font-size: 1.25rem; +} + +.add-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--surface); + border-radius: 6px; +} + +.add-form input, +.add-form select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--muted); + border-radius: 4px; + background: var(--bg); + color: var(--text); +} + +.add-form input[type="text"] { + min-width: 140px; +} + +.add-form input[type="number"] { + width: 80px; +} + +.add-form button { + padding: 0.5rem 1rem; + background: var(--accent); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.add-form button:hover { + opacity: 0.9; +} + +.date-range-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--surface); + border-radius: 6px; +} + +.date-range-form label { + font-size: 0.875rem; + color: var(--muted); +} + +.date-range-form input { + padding: 0.4rem 0.6rem; + border: 1px solid var(--muted); + border-radius: 4px; + background: var(--bg); + color: var(--text); +} + +.date-range-form button { + padding: 0.4rem 0.75rem; + background: var(--accent); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.date-range-form a { + margin-left: 0.5rem; +} + +.period-section { + margin-bottom: 1.5rem; +} + +.period-section h3 { + margin: 0 0 0.5rem 0; + font-size: 1rem; +} + +.preset-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.preset-btn { + display: inline-block; + padding: 0.4rem 0.75rem; + background: var(--surface); + color: var(--text); + border: 1px solid var(--muted); + border-radius: 4px; + text-decoration: none; + font-size: 0.875rem; +} + +.preset-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.preset-btn.preset-active { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +.period-label { + margin: 0 0 0.5rem 0; + font-size: 0.875rem; + color: var(--muted); +} + +.chart-container { + background: var(--surface); + border-radius: 6px; + padding: 1rem; + margin-bottom: 1.5rem; +} + +.chart-container h3 { + margin: 0 0 1rem 0; + font-size: 1rem; +} + +.chart-wrapper { + height: 200px; + position: relative; + overflow: hidden; +} + +#response-chart { + max-height: 200px; +} + +.chart-empty { + color: var(--muted); + text-align: center; + padding: 2rem; + margin: 0; +} + +.checks-filter-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.checks-filter-form select, +.checks-filter-form input[type="text"] { + padding: 0.4rem 0.6rem; + border: 1px solid var(--muted); + border-radius: 4px; + background: var(--bg); + color: var(--text); +} + +.checks-filter-form input[type="text"] { + min-width: 180px; +} + +.checks-filter-form button { + padding: 0.4rem 0.75rem; + background: var(--accent); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.error-cell { + font-size: 0.875rem; + color: var(--muted); + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; +} + +.btn-delete { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + background: transparent; + color: var(--muted); + border: 1px solid var(--muted); + border-radius: 4px; + cursor: pointer; +} + +.btn-delete:hover { + color: var(--down); + border-color: var(--down); +} + +.services-table, +.checks-table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border-radius: 6px; + overflow: hidden; +} + +.services-table th, +.services-table td, +.checks-table th, +.checks-table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--bg); +} + +.services-table th, +.checks-table th { + background: rgba(0, 0, 0, 0.2); + font-weight: 600; +} + +.services-table tbody tr:hover, +.checks-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} + +code { + font-size: 0.9em; + background: rgba(0, 0, 0, 0.3); + padding: 0.2em 0.4em; + border-radius: 4px; +} + +.badge { + display: inline-block; + padding: 0.2em 0.5em; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; +} + +.badge-up { + background: rgba(34, 197, 94, 0.2); + color: var(--up); +} + +.badge-down { + background: rgba(239, 68, 68, 0.2); + color: var(--down); +} + +.badge-pending { + background: rgba(234, 179, 8, 0.2); + color: var(--pending); +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: var(--surface); + padding: 1rem; + border-radius: 6px; + text-align: center; + min-width: 0; + overflow: hidden; +} + +.stat-label { + display: block; + font-size: 0.75rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stat-value { + font-size: 1.25rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..e8735a3 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,18 @@ + + + + + + {% block title %}Status Monitor{% endblock %} + + + +
+

Status Monitor

+ v{{ version }} +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..003ca9a --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Status Monitor{% endblock %} + +{% block content %} +

Services

+ +
+ + + + + +
+ + + + + + + + + + + + + + + + + + {% for s in services %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
NameTargetProtocolStatusUptimeLast CheckResponseReport
{{ s.name }}{{ s.target }}{{ s.protocol | upper }} + {% if s.last_success is none %} + Pending + {% elif s.last_success %} + Up + {% else %} + Down + {% endif %} + + {% if s.uptime_pct is not none %} + {{ s.uptime_pct }}% + {% else %} + - + {% endif %} + {{ s.last_check[:19] if s.last_check else '-' }} + {% if s.last_response_ms is not none %} + {{ (s.last_response_ms | round(0) | int) }} ms + {% else %} + - + {% endif %} + + Report + Edit + + +
No services yet. Add one above.
+{% endblock %} diff --git a/templates/edit.html b/templates/edit.html new file mode 100644 index 0000000..af5d856 --- /dev/null +++ b/templates/edit.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block title %}Edit {{ service.name }}{% endblock %} + +{% block content %} +

Edit Service

+ +
+ + + + + +
+ +

← Back to Dashboard | View Report

+ + +{% endblock %} diff --git a/templates/report.html b/templates/report.html new file mode 100644 index 0000000..dee2e44 --- /dev/null +++ b/templates/report.html @@ -0,0 +1,177 @@ +{% extends "base.html" %} + +{% block title %}{{ service.name }} - Report{% endblock %} + +{% block content %} +

{{ service.name }}

+

+ {{ service.protocol | upper }}: {{ service.target }} + Edit +

+ +
+

Uptime by Period

+ + {% if period_label %} +

Showing: {{ period_label }}

+ {% endif %} +
+ + + + + + {% if from_date or to_date %} + Clear + {% endif %} +
+
+ +
+
+ Uptime{% if from_date or to_date %} (period){% endif %} + {{ stats.uptime_pct }}% +
+
+ Checks + {{ stats.total }} +
+
+ Avg Latency + {{ stats.avg_ms or '-' }}{% if stats.avg_ms %} ms{% endif %} +
+
+ Min + {{ (stats.min_ms | round(0) | int) if stats.min_ms is not none else '-' }}{% if stats.min_ms is not none %} ms{% endif %} +
+
+ Max + {{ (stats.max_ms | round(0) | int) if stats.max_ms is not none else '-' }}{% if stats.max_ms is not none %} ms{% endif %} +
+
+ +
+

Response Time

+ {% if chart_checks %} +
+ +
+ {% else %} +

No check data yet. Checks will appear after the first run.

+ {% endif %} +
+ +

Recent Checks

+
+ + + + + + +
+ + + + + + + + + + + {% for c in checks %} + + + + + + + {% else %} + + + + {% endfor %} + +
TimestampStatusResponse (ms)Error / Reason
{{ c.timestamp[:19] }} + {% if c.success %} + OK + {% else %} + ERROR + {% endif %} + {{ (c.response_time_ms | round(0) | int) if c.response_time_ms is not none else '-' }}{{ c.error_message or '-' }}
No checks yet.{% if status_filter or search %} No matches for filter.{% endif %}
+ +

+ ← Back to Dashboard + + + +

+{% if chart_checks %} + + +{% endif %} + +{% endblock %}