This commit is contained in:
2026-03-07 03:43:50 +00:00
parent 365cb6692e
commit 17d9033152
17 changed files with 1349 additions and 88 deletions

View File

@@ -4,3 +4,5 @@ Jenkinsfile
README.md
.env*
*.md
data/
*.db

View File

@@ -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

142
README.md
View File

@@ -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}`

29
app.py
View File

@@ -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"<h1>Hello from Docker!</h1><p>Version: {VERSION}</p>".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)

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Statping clone - HTTP/HTTPS and TCP monitoring with history and reports."""

64
app/checker.py Normal file
View File

@@ -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)

203
app/main.py Normal file
View File

@@ -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/<int:service_id>", 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/<int:service_id>", 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/<int:service_id>")
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/<int:service_id>/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/<int:service_id>/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/<int:service_id>/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/<int:service_id>/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}

221
app/models.py Normal file
View File

@@ -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]

41
app/scheduler.py Normal file
View File

@@ -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()

BIN
data/monitor.db Normal file

Binary file not shown.

View File

@@ -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

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
flask>=3.0
requests>=2.31
apscheduler>=3.10

369
static/style.css Normal file
View File

@@ -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;
}

18
templates/base.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Status Monitor{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<h1><a href="/">Status Monitor</a></h1>
<span class="version">v{{ version }}</span>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

111
templates/dashboard.html Normal file
View File

@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}Dashboard - Status Monitor{% endblock %}
{% block content %}
<h2>Services</h2>
<form id="add-service-form" class="add-form">
<input type="text" name="name" placeholder="Service name" required>
<input type="text" name="target" placeholder="URL or host:port" required>
<select name="protocol">
<option value="https">HTTPS</option>
<option value="http">HTTP</option>
<option value="tcp">TCP</option>
</select>
<input type="number" name="interval_seconds" value="60" min="10" max="3600" title="Check interval (seconds)">
<button type="submit">Add Service</button>
</form>
<script>
document.getElementById('add-service-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const payload = {
name: form.name.value.trim(),
target: form.target.value.trim(),
protocol: form.protocol.value,
interval_seconds: parseInt(form.interval_seconds.value, 10) || 60
};
const res = await fetch('{{ url_for("api_add_service") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
window.location.reload();
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Failed to add service');
}
});
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
e.preventDefault();
if (!confirm('Delete "' + btn.dataset.name + '"?')) return;
const res = await fetch('/api/services/' + btn.dataset.id, { method: 'DELETE' });
if (res.ok) window.location.reload();
else alert('Failed to delete');
});
</script>
<table class="services-table">
<thead>
<tr>
<th>Name</th>
<th>Target</th>
<th>Protocol</th>
<th>Status</th>
<th>Uptime</th>
<th>Last Check</th>
<th>Response</th>
<th>Report</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in services %}
<tr>
<td>{{ s.name }}</td>
<td><code>{{ s.target }}</code></td>
<td>{{ s.protocol | upper }}</td>
<td>
{% if s.last_success is none %}
<span class="badge badge-pending">Pending</span>
{% elif s.last_success %}
<span class="badge badge-up">Up</span>
{% else %}
<span class="badge badge-down">Down</span>
{% endif %}
</td>
<td>
{% if s.uptime_pct is not none %}
<a href="{{ url_for('report', service_id=s.id) }}">{{ s.uptime_pct }}%</a>
{% else %}
-
{% endif %}
</td>
<td>{{ s.last_check[:19] if s.last_check else '-' }}</td>
<td>
{% if s.last_response_ms is not none %}
{{ (s.last_response_ms | round(0) | int) }} ms
{% else %}
-
{% endif %}
</td>
<td>
<a href="{{ url_for('report', service_id=s.id) }}">Report</a>
<a href="{{ url_for('edit_service', service_id=s.id) }}" style="margin-left: 0.5rem;">Edit</a>
</td>
<td>
<button type="button" class="btn-delete" data-id="{{ s.id }}" data-name="{{ s.name }}" title="Delete">Delete</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="9">No services yet. Add one above.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

45
templates/edit.html Normal file
View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Edit {{ service.name }}{% endblock %}
{% block content %}
<h2>Edit Service</h2>
<form id="edit-service-form" class="add-form">
<input type="text" name="name" value="{{ service.name }}" placeholder="Service name" required>
<input type="text" name="target" value="{{ service.target }}" placeholder="URL or host:port" required>
<select name="protocol">
<option value="https" {% if service.protocol == 'https' %}selected{% endif %}>HTTPS</option>
<option value="http" {% if service.protocol == 'http' %}selected{% endif %}>HTTP</option>
<option value="tcp" {% if service.protocol == 'tcp' %}selected{% endif %}>TCP</option>
</select>
<input type="number" name="interval_seconds" value="{{ service.interval_seconds }}" min="10" max="3600" title="Check interval (seconds)">
<button type="submit">Save</button>
</form>
<p><a href="/">&larr; Back to Dashboard</a> | <a href="{{ url_for('report', service_id=service.id) }}">View Report</a></p>
<script>
document.getElementById('edit-service-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const payload = {
name: form.name.value.trim(),
target: form.target.value.trim(),
protocol: form.protocol.value,
interval_seconds: parseInt(form.interval_seconds.value, 10) || 60
};
const res = await fetch('/api/services/{{ service.id }}', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
window.location.href = '/';
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Failed to update service');
}
});
</script>
{% endblock %}

177
templates/report.html Normal file
View File

@@ -0,0 +1,177 @@
{% extends "base.html" %}
{% block title %}{{ service.name }} - Report{% endblock %}
{% block content %}
<h2>{{ service.name }}</h2>
<p>
<code>{{ service.protocol | upper }}: {{ service.target }}</code>
<a href="{{ url_for('edit_service', service_id=service.id) }}" style="margin-left: 1rem;">Edit</a>
</p>
<div class="period-section">
<h3>Uptime by Period</h3>
<div class="preset-buttons">
<a href="{{ url_for('report', service_id=service.id) }}" class="preset-btn{% if not preset %} preset-active{% endif %}">All time</a>
<a href="{{ url_for('report', service_id=service.id, preset='24h') }}" class="preset-btn{% if preset == '24h' %} preset-active{% endif %}">Last 24h</a>
<a href="{{ url_for('report', service_id=service.id, preset='7d') }}" class="preset-btn{% if preset == '7d' %} preset-active{% endif %}">Last 7 days</a>
<a href="{{ url_for('report', service_id=service.id, preset='30d') }}" class="preset-btn{% if preset == '30d' %} preset-active{% endif %}">Last 30 days</a>
</div>
{% if period_label %}
<p class="period-label">Showing: {{ period_label }}</p>
{% endif %}
<form method="get" action="{{ url_for('report', service_id=service.id) }}" class="date-range-form">
<label>From</label>
<input type="datetime-local" name="from" value="{{ from_date }}" placeholder="Start (optional)">
<label>To</label>
<input type="datetime-local" name="to" value="{{ to_date }}" placeholder="End (optional)">
<button type="submit">Apply</button>
{% if from_date or to_date %}
<a href="{{ url_for('report', service_id=service.id) }}">Clear</a>
{% endif %}
</form>
</div>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Uptime{% if from_date or to_date %} (period){% endif %}</span>
<span class="stat-value">{{ stats.uptime_pct }}%</span>
</div>
<div class="stat-card">
<span class="stat-label">Checks</span>
<span class="stat-value">{{ stats.total }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Avg Latency</span>
<span class="stat-value">{{ stats.avg_ms or '-' }}{% if stats.avg_ms %} ms{% endif %}</span>
</div>
<div class="stat-card">
<span class="stat-label">Min</span>
<span class="stat-value">{{ (stats.min_ms | round(0) | int) if stats.min_ms is not none else '-' }}{% if stats.min_ms is not none %} ms{% endif %}</span>
</div>
<div class="stat-card">
<span class="stat-label">Max</span>
<span class="stat-value">{{ (stats.max_ms | round(0) | int) if stats.max_ms is not none else '-' }}{% if stats.max_ms is not none %} ms{% endif %}</span>
</div>
</div>
<div class="chart-container">
<h3>Response Time</h3>
{% if chart_checks %}
<div class="chart-wrapper">
<canvas id="response-chart"></canvas>
</div>
{% else %}
<p class="chart-empty">No check data yet. Checks will appear after the first run.</p>
{% endif %}
</div>
<h3>Recent Checks</h3>
<form method="get" action="{{ url_for('report', service_id=service.id) }}" class="checks-filter-form">
<input type="hidden" name="preset" value="{{ preset or '' }}">
<input type="hidden" name="from" value="{{ from_date }}">
<input type="hidden" name="to" value="{{ to_date }}">
<select name="status">
<option value="">All</option>
<option value="ok" {% if status_filter == 'ok' %}selected{% endif %}>OK only</option>
<option value="error" {% if status_filter == 'error' %}selected{% endif %}>Errors only</option>
</select>
<input type="text" name="search" value="{{ search }}" placeholder="Search error message...">
<button type="submit">Filter</button>
</form>
<table class="checks-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Status</th>
<th>Response (ms)</th>
<th>Error / Reason</th>
</tr>
</thead>
<tbody>
{% for c in checks %}
<tr>
<td>{{ c.timestamp[:19] }}</td>
<td>
{% if c.success %}
<span class="badge badge-up">OK</span>
{% else %}
<span class="badge badge-down">ERROR</span>
{% endif %}
</td>
<td>{{ (c.response_time_ms | round(0) | int) if c.response_time_ms is not none else '-' }}</td>
<td class="error-cell" {% if c.error_message %}title="{{ c.error_message | e }}"{% endif %}>{{ c.error_message or '-' }}</td>
</tr>
{% else %}
<tr>
<td colspan="4">No checks yet.{% if status_filter or search %} No matches for filter.{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>
<a href="/">&larr; Back to Dashboard</a>
<span style="margin-left: 1rem;">
<button type="button" class="btn-delete" data-id="{{ service.id }}" data-name="{{ service.name }}">Delete Service</button>
</span>
</p>
{% if chart_checks %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
(function() {
const canvas = document.getElementById('response-chart');
if (!canvas) return;
const chartData = {{ chart_checks | tojson }};
if (chartData.length === 0) return;
const reversed = chartData.slice().reverse();
const labels = reversed.map(c => c.timestamp ? c.timestamp.slice(0, 19).replace('T', ' ') : '');
const values = reversed.map(c => c.response_time_ms != null ? c.response_time_ms : null);
const successColors = reversed.map(c => c.success ? 'rgba(34, 197, 94, 0.8)' : 'rgba(239, 68, 68, 0.8)');
new Chart(canvas, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Response (ms)',
data: values,
borderColor: 'rgba(59, 130, 246, 0.9)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.2,
pointBackgroundColor: successColors,
pointBorderColor: successColors,
pointRadius: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { maxTicksLimit: 8, maxRotation: 45 },
grid: { color: 'rgba(148, 163, 184, 0.2)' }
},
y: {
beginAtZero: true,
ticks: { callback: v => v + ' ms' },
grid: { color: 'rgba(148, 163, 184, 0.2)' }
}
}
}
});
})();
</script>
{% endif %}
<script>
document.querySelector('.btn-delete')?.addEventListener('click', async () => {
if (!confirm('Delete "{{ service.name }}"?')) return;
const res = await fetch('/api/services/{{ service.id }}', { method: 'DELETE' });
if (res.ok) window.location.href = '/';
else alert('Failed to delete');
});
</script>
{% endblock %}