From e2023abee5972573ea471419251b56fab267957b Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 5 Apr 2026 08:58:18 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20release=20=E2=80=93=20Backup=20Monito?= =?UTF-8?q?r=20with=20MongoDB,=20Dark=20Theme=20UI,=20Borgmatic=20+=20Upti?= =?UTF-8?q?me=20Kuma=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + Dockerfile | 7 + LICENSE | 21 ++ README.md | 164 +++++++++++++++ app.py | 238 +++++++++++++++++++++ compose.yaml | 22 ++ requirements.txt | 4 + static/css/style.css | 486 +++++++++++++++++++++++++++++++++++++++++++ static/js/app.js | 328 +++++++++++++++++++++++++++++ templates/index.html | 102 +++++++++ 10 files changed, 1378 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.py create mode 100644 compose.yaml create mode 100644 requirements.txt create mode 100644 static/css/style.css create mode 100644 static/js/app.js create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0c7d32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.env +.env.* +!.env.example +mongo_data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..217c6c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 9999 +CMD ["gunicorn", "-b", "0.0.0.0:9999", "-w", "2", "--timeout", "30", "app:app"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..950fbb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sascha Lutz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..42c5a78 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# πŸ›‘οΈ Backup Monitor + +A self-hosted backup monitoring dashboard with MongoDB backend, designed for **Borgmatic** (but works with any tool that can send HTTP requests). + +![Dark Theme Dashboard](https://img.shields.io/badge/theme-dark-1a1d27?style=flat-square) ![Python](https://img.shields.io/badge/python-3.12-blue?style=flat-square) ![MongoDB](https://img.shields.io/badge/mongodb-4.4+-green?style=flat-square) ![License](https://img.shields.io/badge/license-MIT-purple?style=flat-square) + +## Features + +- **Dashboard** – Real-time overview of all backup hosts with status cards +- **Host Management** – Add, edit, disable, delete hosts via Web UI (no config files) +- **History** – 90-day retention with per-day calendar heatmap and size charts +- **Detailed Stats** – Duration, original/deduplicated/compressed size, file counts +- **Uptime Kuma Integration** – Automatic push per host after each backup +- **Stale Detection** – Configurable threshold (default: 26h) marks missed backups +- **Auto-Refresh** – Dashboard updates every 30 seconds +- **Dark Theme** – Clean, modern UI with status-colored indicators +- **Zero Config** – Hosts auto-register on first push, or add manually via UI + +## Quick Start + +```bash +# Clone +git clone https://github.com/feldjaeger/backup-monitor.git +cd backup-monitor + +# Start +docker compose up -d + +# Open +open http://localhost:9999 +``` + +## Docker Compose + +```yaml +services: + backup-monitor: + build: . + container_name: backup-monitor + restart: always + ports: + - "9999:9999" + environment: + - MONGO_URI=mongodb://mongo:27017 + - STALE_HOURS=26 # Hours before a host is marked "stale" + depends_on: + - mongo + + mongo: + image: mongo:4.4 # Use 7+ if your CPU supports AVX + container_name: backup-mongo + restart: always + volumes: + - mongo_data:/data/db + +volumes: + mongo_data: +``` + +## Push API + +After each backup, send a POST request: + +```bash +# Minimal push (just hostname + status) +curl -X POST "http://localhost:9999/api/push?host=myserver&status=ok" + +# Full push with stats (JSON) +curl -X POST -H "Content-Type: application/json" \ + -d '{ + "host": "myserver", + "status": "ok", + "duration_sec": 342, + "original_size": 5368709120, + "deduplicated_size": 104857600, + "compressed_size": 83886080, + "nfiles_new": 47, + "nfiles_changed": 12, + "message": "Backup completed successfully" + }' \ + http://localhost:9999/api/push +``` + +### Borgmatic Integration + +Add to your `borgmatic.yml`: + +```yaml +after_backup: + - >- + bash -c ' + STATS=$(borgmatic info --archive latest --json 2>/dev/null | python3 -c " + import sys,json + d=json.load(sys.stdin)[0][\"archives\"][-1] + s=d.get(\"stats\",{}) + print(json.dumps({ + \"host\":\"$(hostname)\", + \"status\":\"ok\", + \"duration_sec\":int(s.get(\"duration\",0)), + \"original_size\":s.get(\"original_size\",0), + \"deduplicated_size\":s.get(\"deduplicated_size\",0), + \"compressed_size\":s.get(\"compressed_size\",0), + \"nfiles_new\":s.get(\"nfiles\",0) + }))" 2>/dev/null || echo "{\"host\":\"$(hostname)\",\"status\":\"ok\"}"); + curl -fsS -m 10 -X POST -H "Content-Type: application/json" -d "$STATS" "http://YOUR_SERVER:9999/api/push" || true + ' + +on_error: + - >- + curl -fsS -m 10 -X POST -H "Content-Type: application/json" + -d '{"host":"'$(hostname)'","status":"error","message":"Backup failed"}' + "http://YOUR_SERVER:9999/api/push" || true +``` + +## API Reference + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/` | Web UI | +| `GET/POST` | `/api/push` | Push backup status (query params or JSON) | +| `GET` | `/api/hosts` | List all hosts with current status | +| `POST` | `/api/hosts` | Add a host `{"name": "...", "kuma_push_url": "..."}` | +| `PUT` | `/api/hosts/` | Update host `{"enabled": bool, "kuma_push_url": "..."}` | +| `DELETE` | `/api/hosts/` | Delete host and all history | +| `GET` | `/api/history/?days=30` | Backup history for a host | +| `GET` | `/api/calendar/?days=30` | Calendar heatmap data (aggregated by day) | +| `GET` | `/api/summary` | Dashboard summary (counts, today stats) | + +## Uptime Kuma Integration + +1. Create a **Push** monitor in Uptime Kuma for each host +2. Copy the push URL (e.g. `https://status.example.com/api/push/borg-myserver?status=up&msg=OK`) +3. In Backup Monitor: Click on a host β†’ Edit β†’ Paste the Kuma Push URL +4. After each backup push, Backup Monitor automatically forwards the status to Uptime Kuma + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MONGO_URI` | `mongodb://mongo:27017` | MongoDB connection string | +| `STALE_HOURS` | `26` | Hours without backup before host is marked stale | + +## Data Retention + +- History entries are automatically deleted after **90 days** (MongoDB TTL index) +- Hosts are never auto-deleted – remove them manually via UI or API + +## Screenshots + +### Dashboard +Dark-themed overview with summary cards, host grid with status badges, and 14-day minibar charts per host. + +### Host Detail +Slide-out drawer with 30-day calendar heatmap, data volume chart, and detailed backup history table. + +## Tech Stack + +- **Backend:** Python 3.12, Flask, Gunicorn +- **Database:** MongoDB 4.4+ +- **Frontend:** Vanilla JS, CSS (no framework dependencies) + +## License + +MIT diff --git a/app.py b/app.py new file mode 100644 index 0000000..11f0770 --- /dev/null +++ b/app.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +Backup Monitor – Backend +MongoDB-backed backup monitoring with Web UI and Uptime Kuma integration. +""" +from flask import Flask, request, jsonify, render_template, send_from_directory +from pymongo import MongoClient, DESCENDING +from datetime import datetime, timedelta +import os, time, requests, logging + +app = Flask(__name__) +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("backup-monitor") + +MONGO_URI = os.environ.get("MONGO_URI", "mongodb://mongo:27017") +KUMA_URL = os.environ.get("KUMA_URL", "") +KUMA_TOKEN = os.environ.get("KUMA_TOKEN", "") +STALE_HOURS = int(os.environ.get("STALE_HOURS", "26")) + +db = MongoClient(MONGO_URI).backup_monitor +db.hosts.create_index("name", unique=True) +db.history.create_index([("host", 1), ("timestamp", -1)]) +db.history.create_index("timestamp", expireAfterSeconds=90 * 86400) # 90 Tage TTL + + +# ── API: Push (called by borgmatic after_backup hook) ────────────────────── + +@app.route("/api/push", methods=["POST", "GET"]) +def push(): + host = request.args.get("host") or request.json.get("host", "") if request.is_json else request.args.get("host") + if not host: + return jsonify({"error": "host required"}), 400 + + data = request.json if request.is_json else {} + now = datetime.utcnow() + + entry = { + "host": host, + "timestamp": now, + "status": data.get("status", request.args.get("status", "ok")), + "duration_sec": data.get("duration_sec", 0), + "original_size": data.get("original_size", 0), + "deduplicated_size": data.get("deduplicated_size", 0), + "compressed_size": data.get("compressed_size", 0), + "nfiles_new": data.get("nfiles_new", 0), + "nfiles_changed": data.get("nfiles_changed", 0), + "message": data.get("message", request.args.get("msg", "")), + } + db.history.insert_one(entry) + + # Update host record + db.hosts.update_one( + {"name": host}, + {"$set": {"last_backup": now, "last_status": entry["status"], "last_message": entry["message"]}, + "$setOnInsert": {"name": host, "enabled": True, "created": now, "kuma_push_url": ""}}, + upsert=True + ) + + # Uptime Kuma push + h = db.hosts.find_one({"name": host}) + if h and h.get("kuma_push_url"): + try: + status_param = "up" if entry["status"] == "ok" else "down" + msg = f"Backup OK – {_fmt_bytes(entry['original_size'])}" if entry["status"] == "ok" else entry["message"] + requests.get(h["kuma_push_url"], params={"status": status_param, "msg": msg}, timeout=5) + except Exception as e: + log.warning(f"Kuma push failed for {host}: {e}") + + return jsonify({"ok": True, "host": host}) + + +# ── API: Hosts CRUD ──────────────────────────────────────────────────────── + +@app.route("/api/hosts", methods=["GET"]) +def list_hosts(): + hosts = [] + now = datetime.utcnow() + for h in db.hosts.find().sort("name", 1): + age_h = (now - h.get("last_backup", now)).total_seconds() / 3600 if h.get("last_backup") else 999 + if not h.get("enabled", True): + status = "disabled" + elif age_h > STALE_HOURS: + status = "stale" + elif h.get("last_status") == "error": + status = "error" + else: + status = "ok" + + # Last 7 days summary + week_ago = now - timedelta(days=7) + recent = list(db.history.find({"host": h["name"], "timestamp": {"$gte": week_ago}}).sort("timestamp", -1)) + + hosts.append({ + "name": h["name"], + "enabled": h.get("enabled", True), + "status": status, + "last_backup": h.get("last_backup", "").isoformat() + "Z" if h.get("last_backup") else None, + "last_status": h.get("last_status", "unknown"), + "last_message": h.get("last_message", ""), + "age_hours": round(age_h, 1), + "kuma_push_url": h.get("kuma_push_url", ""), + "backup_count_7d": len(recent), + "total_size_7d": sum(r.get("original_size", 0) for r in recent), + "avg_duration_7d": round(sum(r.get("duration_sec", 0) for r in recent) / max(len(recent), 1)), + }) + return jsonify(hosts) + + +@app.route("/api/hosts", methods=["POST"]) +def add_host(): + data = request.json + name = data.get("name", "").strip() + if not name: + return jsonify({"error": "name required"}), 400 + db.hosts.update_one( + {"name": name}, + {"$setOnInsert": {"name": name, "enabled": True, "created": datetime.utcnow(), + "kuma_push_url": data.get("kuma_push_url", "")}}, + upsert=True + ) + return jsonify({"ok": True, "name": name}) + + +@app.route("/api/hosts/", methods=["PUT"]) +def update_host(name): + data = request.json + update = {} + if "enabled" in data: + update["enabled"] = data["enabled"] + if "kuma_push_url" in data: + update["kuma_push_url"] = data["kuma_push_url"] + if update: + db.hosts.update_one({"name": name}, {"$set": update}) + return jsonify({"ok": True}) + + +@app.route("/api/hosts/", methods=["DELETE"]) +def delete_host(name): + db.hosts.delete_one({"name": name}) + db.history.delete_many({"host": name}) + return jsonify({"ok": True}) + + +# ── API: History ─────────────────────────────────────────────────────────── + +@app.route("/api/history/") +def host_history(host): + days = int(request.args.get("days", 30)) + since = datetime.utcnow() - timedelta(days=days) + entries = [] + for e in db.history.find({"host": host, "timestamp": {"$gte": since}}).sort("timestamp", DESCENDING): + entries.append({ + "timestamp": e["timestamp"].isoformat() + "Z", + "status": e.get("status", "ok"), + "duration_sec": e.get("duration_sec", 0), + "original_size": e.get("original_size", 0), + "deduplicated_size": e.get("deduplicated_size", 0), + "compressed_size": e.get("compressed_size", 0), + "nfiles_new": e.get("nfiles_new", 0), + "nfiles_changed": e.get("nfiles_changed", 0), + "message": e.get("message", ""), + }) + return jsonify(entries) + + +@app.route("/api/calendar/") +def host_calendar(host): + """30-day calendar heatmap data.""" + days = int(request.args.get("days", 30)) + since = datetime.utcnow() - timedelta(days=days) + pipeline = [ + {"$match": {"host": host, "timestamp": {"$gte": since}}}, + {"$group": { + "_id": {"$dateToString": {"format": "%Y-%m-%d", "date": "$timestamp"}}, + "count": {"$sum": 1}, + "total_size": {"$sum": "$original_size"}, + "has_error": {"$max": {"$cond": [{"$eq": ["$status", "error"]}, 1, 0]}}, + "avg_duration": {"$avg": "$duration_sec"}, + }}, + {"$sort": {"_id": 1}} + ] + result = {} + for day in db.history.aggregate(pipeline): + result[day["_id"]] = { + "count": day["count"], + "total_size": day["total_size"], + "has_error": bool(day["has_error"]), + "avg_duration": round(day.get("avg_duration", 0)), + } + return jsonify(result) + + +# ── API: Dashboard summary ───────────────────────────────────────────────── + +@app.route("/api/summary") +def summary(): + now = datetime.utcnow() + hosts = list(db.hosts.find({"enabled": True})) + total = len(hosts) + ok = stale = error = 0 + for h in hosts: + age_h = (now - h.get("last_backup", now)).total_seconds() / 3600 if h.get("last_backup") else 999 + if age_h > STALE_HOURS: + stale += 1 + elif h.get("last_status") == "error": + error += 1 + else: + ok += 1 + + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + today_backups = db.history.count_documents({"timestamp": {"$gte": today}}) + today_size = sum(e.get("original_size", 0) for e in db.history.find({"timestamp": {"$gte": today}})) + + return jsonify({ + "total_hosts": total, "ok": ok, "stale": stale, "error": error, + "today_backups": today_backups, "today_size": today_size, + }) + + +# ── Web UI ───────────────────────────────────────────────────────────────── + +@app.route("/") +def index(): + return render_template("index.html") + + +# ── Helpers ──────────────────────────────────────────────────────────────── + +def _fmt_bytes(b): + for unit in ["B", "KB", "MB", "GB", "TB"]: + if b < 1024: + return f"{b:.1f} {unit}" + b /= 1024 + return f"{b:.1f} PB" + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=9999, debug=False) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..2a8b549 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,22 @@ +services: + backup-monitor: + build: . + container_name: backup-monitor + restart: always + ports: + - "9999:9999" + environment: + - MONGO_URI=mongodb://mongo:27017 + - STALE_HOURS=26 + depends_on: + - mongo + + mongo: + image: mongo:4.4 + container_name: backup-mongo + restart: always + volumes: + - mongo_data:/data/db + +volumes: + mongo_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ae30eb6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.1.* +pymongo==4.12.* +requests==2.32.* +gunicorn==23.* diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..c1f5373 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,486 @@ +/* ── Backup Monitor – Dark Theme UI ─────────────────────────── */ + +:root { + --bg: #0f1117; + --bg-card: #1a1d27; + --bg-card-hover: #22263a; + --bg-drawer: #161922; + --border: #2a2e3d; + --text: #e4e6f0; + --text-dim: #8b8fa3; + --text-muted: #5c6078; + --accent: #6c5ce7; + --accent-glow: rgba(108, 92, 231, 0.3); + --ok: #00d68f; + --ok-bg: rgba(0, 214, 143, 0.1); + --ok-glow: rgba(0, 214, 143, 0.25); + --warn: #ffaa00; + --warn-bg: rgba(255, 170, 0, 0.1); + --warn-glow: rgba(255, 170, 0, 0.25); + --error: #ff3d71; + --error-bg: rgba(255, 61, 113, 0.1); + --error-glow: rgba(255, 61, 113, 0.25); + --disabled: #4a4e69; + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 4px 24px rgba(0,0,0,0.3); + --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + overflow-x: hidden; +} + +/* ── Header ────────────────────────────────────────────────── */ + +header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: blur(12px); +} + +.header-left { display: flex; align-items: center; gap: 12px; } +.logo { font-size: 28px; } +h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; } +.subtitle { color: var(--text-dim); font-size: 13px; } + +.header-right { display: flex; align-items: center; gap: 12px; } +.meta-text { color: var(--text-muted); font-size: 12px; } + +.pulse-dot { + width: 10px; height: 10px; + border-radius: 50%; + background: var(--ok); + box-shadow: 0 0 8px var(--ok-glow); + animation: pulse 2s infinite; +} +.pulse-dot.warn { background: var(--warn); box-shadow: 0 0 8px var(--warn-glow); } +.pulse-dot.error { background: var(--error); box-shadow: 0 0 8px var(--error-glow); } + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(0.85); } +} + +/* ── Buttons ───────────────────────────────────────────────── */ + +.btn { + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-card); + color: var(--text); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all var(--transition); +} +.btn:hover { background: var(--bg-card-hover); border-color: var(--accent); } +.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-primary:hover { background: #7c6cf0; box-shadow: 0 0 20px var(--accent-glow); } +.btn-icon { padding: 8px 10px; font-size: 16px; line-height: 1; } +.btn-danger { color: var(--error); } +.btn-danger:hover { background: var(--error-bg); border-color: var(--error); } +.btn-sm { padding: 4px 10px; font-size: 12px; } + +/* ── Summary Grid ──────────────────────────────────────────── */ + +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + padding: 24px; +} + +.summary-card { + text-align: center; + padding: 20px 16px; + position: relative; + overflow: hidden; +} +.summary-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + background: var(--border); + border-radius: var(--radius) var(--radius) 0 0; +} +.card-ok::before { background: var(--ok); box-shadow: 0 0 12px var(--ok-glow); } +.card-warn::before { background: var(--warn); box-shadow: 0 0 12px var(--warn-glow); } +.card-error::before { background: var(--error); box-shadow: 0 0 12px var(--error-glow); } + +.card-value { font-size: 32px; font-weight: 800; letter-spacing: -1px; } +.card-ok .card-value { color: var(--ok); } +.card-warn .card-value { color: var(--warn); } +.card-error .card-value { color: var(--error); } +.card-label { color: var(--text-dim); font-size: 12px; margin-top: 4px; text-transform: uppercase; letter-spacing: 1px; } + +/* ── Cards ─────────────────────────────────────────────────── */ + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + transition: all var(--transition); +} +.card:hover { border-color: var(--accent); box-shadow: var(--shadow); } + +/* ── Host Grid ─────────────────────────────────────────────── */ + +.host-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; + padding: 0 24px 24px; +} + +.host-card { + cursor: pointer; + padding: 20px; + position: relative; + overflow: hidden; +} +.host-card::after { + content: ''; + position: absolute; + top: 0; bottom: 0; left: 0; + width: 4px; + border-radius: var(--radius) 0 0 var(--radius); +} +.host-card[data-status="ok"]::after { background: var(--ok); box-shadow: 0 0 8px var(--ok-glow); } +.host-card[data-status="stale"]::after { background: var(--warn); box-shadow: 0 0 8px var(--warn-glow); } +.host-card[data-status="error"]::after { background: var(--error); box-shadow: 0 0 8px var(--error-glow); } +.host-card[data-status="disabled"]::after { background: var(--disabled); } +.host-card[data-status="disabled"] { opacity: 0.5; } + +.host-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } +.host-name { font-size: 16px; font-weight: 700; letter-spacing: -0.3px; } +.host-badge { + padding: 3px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.badge-ok { background: var(--ok-bg); color: var(--ok); } +.badge-stale { background: var(--warn-bg); color: var(--warn); } +.badge-error { background: var(--error-bg); color: var(--error); } +.badge-disabled { background: rgba(74,78,105,0.2); color: var(--disabled); } + +.host-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.meta-item { display: flex; flex-direction: column; } +.meta-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.meta-value { font-size: 14px; font-weight: 600; margin-top: 2px; } + +.host-minibar { + display: flex; + gap: 2px; + margin-top: 14px; + height: 24px; + align-items: flex-end; +} +.minibar-day { + flex: 1; + border-radius: 2px; + min-height: 3px; + transition: all var(--transition); + position: relative; +} +.minibar-day:hover { opacity: 0.8; transform: scaleY(1.15); } +.minibar-day.ok { background: var(--ok); } +.minibar-day.error { background: var(--error); } +.minibar-day.empty { background: var(--border); min-height: 3px; } + +/* ── Drawer ────────────────────────────────────────────────── */ + +.drawer-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); + z-index: 200; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; +} +.drawer-overlay.open { opacity: 1; pointer-events: all; } + +.drawer { + position: fixed; + top: 0; right: -520px; bottom: 0; + width: 500px; + max-width: 90vw; + background: var(--bg-drawer); + border-left: 1px solid var(--border); + z-index: 201; + transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; + box-shadow: -8px 0 40px rgba(0,0,0,0.4); +} +.drawer.open { right: 0; } + +.drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--border); +} +.drawer-header h2 { font-size: 18px; font-weight: 700; } + +.drawer-body { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +/* ── Drawer: Calendar Heatmap ──────────────────────────────── */ + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin: 16px 0; +} +.cal-day { + aspect-ratio: 1; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + color: var(--text-muted); + cursor: default; + transition: all var(--transition); + position: relative; +} +.cal-day:hover { transform: scale(1.15); z-index: 1; } +.cal-day.empty { background: var(--bg-card); } +.cal-day.ok { background: var(--ok); color: #fff; font-weight: 700; } +.cal-day.error { background: var(--error); color: #fff; font-weight: 700; } +.cal-day.future { background: transparent; border: 1px dashed var(--border); } + +.cal-day .tooltip { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + font-size: 11px; + white-space: nowrap; + z-index: 10; + box-shadow: var(--shadow); + color: var(--text); +} +.cal-day:hover .tooltip { display: block; } + +/* ── Drawer: History Table ─────────────────────────────────── */ + +.history-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + margin-top: 16px; +} +.history-table th { + text-align: left; + padding: 8px 12px; + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); +} +.history-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} +.history-table tr:hover td { background: var(--bg-card-hover); } + +/* ── Drawer: Stats Row ─────────────────────────────────────── */ + +.stats-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 16px 0; +} +.stat-box { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 14px; + text-align: center; +} +.stat-value { font-size: 22px; font-weight: 800; color: var(--accent); } +.stat-label { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; } + +/* ── Drawer: Size Chart ────────────────────────────────────── */ + +.size-chart { + display: flex; + align-items: flex-end; + gap: 3px; + height: 80px; + margin: 16px 0; + padding: 0 2px; +} +.size-bar { + flex: 1; + background: var(--accent); + border-radius: 3px 3px 0 0; + min-height: 2px; + transition: all var(--transition); + position: relative; + opacity: 0.7; +} +.size-bar:hover { opacity: 1; } +.size-bar .tooltip { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 10px; + font-size: 11px; + white-space: nowrap; + z-index: 10; + box-shadow: var(--shadow); + color: var(--text); +} +.size-bar:hover .tooltip { display: block; } + +/* ── Modal ─────────────────────────────────────────────────── */ + +.modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); + z-index: 300; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; +} +.modal-overlay.open { opacity: 1; pointer-events: all; } + +.modal { + position: fixed; + top: 50%; left: 50%; + transform: translate(-50%, -50%) scale(0.95); + background: var(--bg-drawer); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0; + width: 440px; + max-width: 90vw; + z-index: 301; + opacity: 0; + pointer-events: none; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 20px 60px rgba(0,0,0,0.5); +} +.modal.open { opacity: 1; pointer-events: all; transform: translate(-50%, -50%) scale(1); } + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--border); +} +.modal-header h2 { font-size: 16px; font-weight: 700; } + +.modal form { padding: 24px; } +.form-group { margin-bottom: 16px; } +.form-group label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; } +.form-group input[type="text"], +.form-group input[type="url"] { + width: 100%; + padding: 10px 14px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 14px; + transition: border-color var(--transition); +} +.form-group input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } +.checkbox-label { display: flex !important; align-items: center; gap: 8px; cursor: pointer; font-size: 14px !important; text-transform: none !important; letter-spacing: 0 !important; color: var(--text) !important; } +.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; } + +/* ── Toast ─────────────────────────────────────────────────── */ + +.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 400; display: flex; flex-direction: column; gap: 8px; } +.toast { + padding: 12px 20px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 13px; + box-shadow: var(--shadow); + animation: slideIn 0.3s ease-out; + display: flex; + align-items: center; + gap: 8px; +} +.toast.success { border-left: 3px solid var(--ok); } +.toast.error { border-left: 3px solid var(--error); } + +@keyframes slideIn { + from { transform: translateX(100px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* ── Section Headers ───────────────────────────────────────── */ + +.section-header { + font-size: 13px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; + margin: 20px 0 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +/* ── Drawer Actions ────────────────────────────────────────── */ + +.drawer-actions { + display: flex; + gap: 8px; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +/* ── Responsive ────────────────────────────────────────────── */ + +@media (max-width: 768px) { + header { flex-direction: column; gap: 12px; } + .summary-grid { grid-template-columns: repeat(3, 1fr); padding: 16px; } + .host-grid { grid-template-columns: 1fr; padding: 0 16px 16px; } + .drawer { width: 100%; max-width: 100vw; } + .stats-row { grid-template-columns: 1fr; } +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..c4e6cd0 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,328 @@ +/* ── Backup Monitor – Frontend Logic ────────────────────────── */ + +const API = ''; +let refreshTimer; + +// ── Init ────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + loadAll(); + refreshTimer = setInterval(loadAll, 30000); +}); + +async function loadAll() { + await Promise.all([loadSummary(), loadHosts()]); + document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE'); +} + +// ── Summary ─────────────────────────────────────────────────── +async function loadSummary() { + const r = await fetch(`${API}/api/summary`); + const d = await r.json(); + document.getElementById('valTotal').textContent = d.total_hosts; + document.getElementById('valOk').textContent = d.ok; + document.getElementById('valStale').textContent = d.stale; + document.getElementById('valError').textContent = d.error; + document.getElementById('valToday').textContent = d.today_backups; + document.getElementById('valSize').textContent = fmtBytes(d.today_size); + + const pulse = document.getElementById('globalPulse'); + pulse.className = 'pulse-dot'; + if (d.error > 0) pulse.classList.add('error'); + else if (d.stale > 0) pulse.classList.add('warn'); +} + +// ── Host Grid ───────────────────────────────────────────────── +async function loadHosts() { + const r = await fetch(`${API}/api/hosts`); + const hosts = await r.json(); + const grid = document.getElementById('hostGrid'); + + grid.innerHTML = hosts.map(h => ` +
+
+ ${h.name} + ${statusLabel(h.status)} +
+
+
+ Letztes Backup + ${h.last_backup ? timeAgo(h.last_backup) : 'Nie'} +
+
+ 7-Tage + ${h.backup_count_7d} Backups +
+
+ Ø Dauer + ${fmtDuration(h.avg_duration_7d)} +
+
+ 7-Tage Volumen + ${fmtBytes(h.total_size_7d)} +
+
+
+
+ `).join(''); + + // Load minibars + for (const h of hosts) { + loadMinibar(h.name); + } +} + +async function loadMinibar(host) { + const r = await fetch(`${API}/api/calendar/${host}?days=14`); + const cal = await r.json(); + const el = document.getElementById(`mini-${host}`); + if (!el) return; + + const days = []; + for (let i = 13; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().split('T')[0]; + days.push({ key, data: cal[key] || null }); + } + + const maxSize = Math.max(...days.map(d => d.data?.total_size || 0), 1); + el.innerHTML = days.map(d => { + if (!d.data) return `
`; + const h = Math.max(15, (d.data.total_size / maxSize) * 100); + const cls = d.data.has_error ? 'error' : 'ok'; + return `
`; + }).join(''); +} + +// ── Host Detail Drawer ──────────────────────────────────────── +async function openHost(name) { + document.getElementById('drawerTitle').textContent = name; + document.getElementById('drawerOverlay').classList.add('open'); + document.getElementById('drawer').classList.add('open'); + + const body = document.getElementById('drawerBody'); + body.innerHTML = '
Lade...
'; + + const [histR, calR, hostsR] = await Promise.all([ + fetch(`${API}/api/history/${name}?days=30`), + fetch(`${API}/api/calendar/${name}?days=30`), + fetch(`${API}/api/hosts`), + ]); + const history = await histR.json(); + const calendar = await calR.json(); + const hosts = await hostsR.json(); + const host = hosts.find(h => h.name === name) || {}; + + // Stats + const totalSize = history.reduce((s, e) => s + e.original_size, 0); + const avgDuration = history.length ? Math.round(history.reduce((s, e) => s + e.duration_sec, 0) / history.length) : 0; + const successRate = history.length ? Math.round(history.filter(e => e.status === 'ok').length / history.length * 100) : 0; + + body.innerHTML = ` +
+
+
${history.length}
+
Backups (30d)
+
+
+
${successRate}%
+
Erfolgsrate
+
+
+
${fmtDuration(avgDuration)}
+
Ø Dauer
+
+
+ +
Kalender (30 Tage)
+
${buildCalendar(calendar)}
+ +
Datenvolumen (30 Tage)
+
${buildSizeChart(history)}
+ +
Letzte Backups
+ + + + ${history.slice(0, 20).map(e => ` + + + + + + + + `).join('')} + +
DatumStatusDauerGrâßeDateien
${new Date(e.timestamp).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})}${e.status}${fmtDuration(e.duration_sec)}${fmtBytes(e.original_size)}${e.nfiles_new ? `+${e.nfiles_new}` : '–'} ${e.nfiles_changed ? `/ ~${e.nfiles_changed}` : ''}
+ +
+ + +
+ `; +} + +function buildCalendar(cal) { + const days = []; + const now = new Date(); + for (let i = 29; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().split('T')[0]; + const data = cal[key]; + const dayNum = d.getDate(); + + if (!data) { + days.push(`
${dayNum}
${key}
Kein Backup
`); + } else { + const cls = data.has_error ? 'error' : 'ok'; + days.push(`
${dayNum}
${key}
${data.count}x Backup
${fmtBytes(data.total_size)}
Ø ${fmtDuration(data.avg_duration)}
`); + } + } + // Future days to fill the row + for (let i = 1; i <= 5; i++) { + const d = new Date(); + d.setDate(d.getDate() + i); + days.push(`
${d.getDate()}
`); + } + return days.join(''); +} + +function buildSizeChart(history) { + // Group by day, last 30 days + const byDay = {}; + history.forEach(e => { + const day = e.timestamp.split('T')[0]; + if (!byDay[day]) byDay[day] = 0; + byDay[day] += e.original_size; + }); + + const days = []; + for (let i = 29; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().split('T')[0]; + days.push({ key, size: byDay[key] || 0 }); + } + + const maxSize = Math.max(...days.map(d => d.size), 1); + return days.map(d => { + const h = d.size ? Math.max(4, (d.size / maxSize) * 100) : 2; + const opacity = d.size ? '' : 'opacity:0.2;'; + return `
${d.key}
${fmtBytes(d.size)}
`; + }).join(''); +} + +function closeDrawer() { + document.getElementById('drawerOverlay').classList.remove('open'); + document.getElementById('drawer').classList.remove('open'); +} + +// ── Add/Edit Host Modal ─────────────────────────────────────── +function openAddHost() { + document.getElementById('modalTitle').textContent = 'Host hinzufΓΌgen'; + document.getElementById('formMode').value = 'add'; + document.getElementById('formName').value = ''; + document.getElementById('formName').disabled = false; + document.getElementById('formKumaUrl').value = ''; + document.getElementById('formEnabled').checked = true; + openModal(); +} + +async function openEditHost(name) { + closeDrawer(); + const r = await fetch(`${API}/api/hosts`); + const hosts = await r.json(); + const h = hosts.find(x => x.name === name); + if (!h) return; + + document.getElementById('modalTitle').textContent = `${name} bearbeiten`; + document.getElementById('formMode').value = 'edit'; + document.getElementById('formName').value = h.name; + document.getElementById('formName').disabled = true; + document.getElementById('formKumaUrl').value = h.kuma_push_url || ''; + document.getElementById('formEnabled').checked = h.enabled; + openModal(); +} + +async function saveHost(e) { + e.preventDefault(); + const mode = document.getElementById('formMode').value; + const name = document.getElementById('formName').value.trim(); + const kuma = document.getElementById('formKumaUrl').value.trim(); + const enabled = document.getElementById('formEnabled').checked; + + if (mode === 'add') { + await fetch(`${API}/api/hosts`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ name, kuma_push_url: kuma }) + }); + toast(`${name} hinzugefΓΌgt`, 'success'); + } else { + await fetch(`${API}/api/hosts/${name}`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ kuma_push_url: kuma, enabled }) + }); + toast(`${name} aktualisiert`, 'success'); + } + + closeModal(); + loadAll(); +} + +async function confirmDelete(name) { + if (!confirm(`Host "${name}" und alle History wirklich lΓΆschen?`)) return; + await fetch(`${API}/api/hosts/${name}`, { method: 'DELETE' }); + toast(`${name} gelΓΆscht`, 'success'); + closeDrawer(); + loadAll(); +} + +function openModal() { + document.getElementById('modalOverlay').classList.add('open'); + document.getElementById('modal').classList.add('open'); +} +function closeModal() { + document.getElementById('modalOverlay').classList.remove('open'); + document.getElementById('modal').classList.remove('open'); +} + +// ── Toast ───────────────────────────────────────────────────── +function toast(msg, type = 'success') { + const c = document.getElementById('toastContainer'); + const t = document.createElement('div'); + t.className = `toast ${type}`; + t.innerHTML = `${type === 'success' ? 'βœ“' : 'βœ•'} ${msg}`; + c.appendChild(t); + setTimeout(() => t.remove(), 4000); +} + +// ── Helpers ─────────────────────────────────────────────────── +function fmtBytes(b) { + if (!b || b === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(b) / Math.log(1024)); + return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]; +} + +function fmtDuration(sec) { + if (!sec || sec === 0) return '–'; + if (sec < 60) return `${sec}s`; + if (sec < 3600) return `${Math.floor(sec/60)}m ${sec%60}s`; + return `${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`; +} + +function timeAgo(iso) { + const diff = (Date.now() - new Date(iso).getTime()) / 1000; + if (diff < 60) return 'gerade eben'; + if (diff < 3600) return `vor ${Math.floor(diff/60)}m`; + if (diff < 86400) return `vor ${Math.floor(diff/3600)}h`; + return `vor ${Math.floor(diff/86400)}d`; +} + +function statusLabel(s) { + return { ok: 'OK', stale: 'ÜberfΓ€llig', error: 'Fehler', disabled: 'Deaktiviert' }[s] || s; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..91f2a71 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,102 @@ + + + + + + Backup Monitor – Pfannkuchen + + + + + +
+
+ +

Backup Monitor

+ Homelab Pfannkuchen +
+
+
+ + + +
+
+ + +
+
+
–
+
Hosts
+
+
+
–
+
OK
+
+
+
–
+
ÜberfÀllig
+
+
+
–
+
Fehler
+
+
+
–
+
Heute
+
+
+
–
+
Heute gesichert
+
+
+ + +
+ + +
+ + + + + + + +
+ + + +