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 %}
+
+
+
+
+
+ {% 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
+
+
+
+
+
+
+
+ | Name |
+ Target |
+ Protocol |
+ Status |
+ Uptime |
+ Last Check |
+ Response |
+ Report |
+ |
+
+
+
+ {% for s in services %}
+
+ | {{ 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
+ |
+
+
+ |
+
+ {% else %}
+
+ | No services yet. Add one above. |
+
+ {% endfor %}
+
+
+{% 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 %}
+
+
+
+
+
+ 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
+
+
+
+
+ | Timestamp |
+ Status |
+ Response (ms) |
+ Error / Reason |
+
+
+
+ {% for c in checks %}
+
+ | {{ 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 '-' }} |
+
+ {% else %}
+
+ | No checks yet.{% if status_filter or search %} No matches for filter.{% endif %} |
+
+ {% endfor %}
+
+
+
+
+ ← Back to Dashboard
+
+
+
+
+{% if chart_checks %}
+
+
+{% endif %}
+
+{% endblock %}