update
This commit is contained in:
@@ -4,3 +4,5 @@ Jenkinsfile
|
||||
README.md
|
||||
.env*
|
||||
*.md
|
||||
data/
|
||||
*.db
|
||||
|
||||
@@ -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
142
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}`
|
||||
|
||||
29
app.py
29
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"<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
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Statping clone - HTTP/HTTPS and TCP monitoring with history and reports."""
|
||||
64
app/checker.py
Normal file
64
app/checker.py
Normal 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
203
app/main.py
Normal 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
221
app/models.py
Normal 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
41
app/scheduler.py
Normal 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
BIN
data/monitor.db
Normal file
Binary file not shown.
@@ -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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask>=3.0
|
||||
requests>=2.31
|
||||
apscheduler>=3.10
|
||||
369
static/style.css
Normal file
369
static/style.css
Normal 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
18
templates/base.html
Normal 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
111
templates/dashboard.html
Normal 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
45
templates/edit.html
Normal 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="/">← 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
177
templates/report.html
Normal 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="/">← 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 %}
|
||||
Reference in New Issue
Block a user