commit e2023abee5972573ea471419251b56fab267957b Author: sascha Date: Sun Apr 5 08:58:18 2026 +0200 Initial release – Backup Monitor with MongoDB, Dark Theme UI, Borgmatic + Uptime Kuma integration 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
+
+
+ + +
+ + +
+ + + + + + + +
+ + + +