Initial release – Backup Monitor with MongoDB, Dark Theme UI, Borgmatic + Uptime Kuma integration

This commit is contained in:
sascha 2026-04-05 08:58:18 +02:00
commit e2023abee5
10 changed files with 1378 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
__pycache__/
*.pyc
.env
.env.*
!.env.example
mongo_data/

7
Dockerfile Normal file
View file

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

21
LICENSE Normal file
View file

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

164
README.md Normal file
View file

@ -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/<name>` | Update host `{"enabled": bool, "kuma_push_url": "..."}` |
| `DELETE` | `/api/hosts/<name>` | Delete host and all history |
| `GET` | `/api/history/<host>?days=30` | Backup history for a host |
| `GET` | `/api/calendar/<host>?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

238
app.py Normal file
View file

@ -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/<name>", 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/<name>", 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/<host>")
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/<host>")
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)

22
compose.yaml Normal file
View file

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

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
Flask==3.1.*
pymongo==4.12.*
requests==2.32.*
gunicorn==23.*

486
static/css/style.css Normal file
View file

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

328
static/js/app.js Normal file
View file

@ -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 => `
<div class="card host-card" data-status="${h.status}" onclick="openHost('${h.name}')">
<div class="host-header">
<span class="host-name">${h.name}</span>
<span class="host-badge badge-${h.status}">${statusLabel(h.status)}</span>
</div>
<div class="host-meta">
<div class="meta-item">
<span class="meta-label">Letztes Backup</span>
<span class="meta-value">${h.last_backup ? timeAgo(h.last_backup) : 'Nie'}</span>
</div>
<div class="meta-item">
<span class="meta-label">7-Tage</span>
<span class="meta-value">${h.backup_count_7d} Backups</span>
</div>
<div class="meta-item">
<span class="meta-label">Ø Dauer</span>
<span class="meta-value">${fmtDuration(h.avg_duration_7d)}</span>
</div>
<div class="meta-item">
<span class="meta-label">7-Tage Volumen</span>
<span class="meta-value">${fmtBytes(h.total_size_7d)}</span>
</div>
</div>
<div class="host-minibar" id="mini-${h.name}"></div>
</div>
`).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 `<div class="minibar-day empty" title="${d.key}: Kein Backup"></div>`;
const h = Math.max(15, (d.data.total_size / maxSize) * 100);
const cls = d.data.has_error ? 'error' : 'ok';
return `<div class="minibar-day ${cls}" style="height:${h}%" title="${d.key}: ${d.data.count}x, ${fmtBytes(d.data.total_size)}"></div>`;
}).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 = '<div style="text-align:center;padding:40px;color:var(--text-muted)">Lade...</div>';
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 = `
<div class="stats-row">
<div class="stat-box">
<div class="stat-value">${history.length}</div>
<div class="stat-label">Backups (30d)</div>
</div>
<div class="stat-box">
<div class="stat-value">${successRate}%</div>
<div class="stat-label">Erfolgsrate</div>
</div>
<div class="stat-box">
<div class="stat-value">${fmtDuration(avgDuration)}</div>
<div class="stat-label">Ø Dauer</div>
</div>
</div>
<div class="section-header">Kalender (30 Tage)</div>
<div class="calendar-grid">${buildCalendar(calendar)}</div>
<div class="section-header">Datenvolumen (30 Tage)</div>
<div class="size-chart">${buildSizeChart(history)}</div>
<div class="section-header">Letzte Backups</div>
<table class="history-table">
<thead><tr><th>Datum</th><th>Status</th><th>Dauer</th><th>Größe</th><th>Dateien</th></tr></thead>
<tbody>
${history.slice(0, 20).map(e => `
<tr>
<td>${new Date(e.timestamp).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})}</td>
<td><span class="host-badge badge-${e.status === 'ok' ? 'ok' : 'error'}">${e.status}</span></td>
<td>${fmtDuration(e.duration_sec)}</td>
<td>${fmtBytes(e.original_size)}</td>
<td>${e.nfiles_new ? `+${e.nfiles_new}` : ''} ${e.nfiles_changed ? `/ ~${e.nfiles_changed}` : ''}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="drawer-actions">
<button class="btn" onclick="openEditHost('${name}')"> Bearbeiten</button>
<button class="btn btn-danger" onclick="confirmDelete('${name}')">🗑 Löschen</button>
</div>
`;
}
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(`<div class="cal-day empty">${dayNum}<div class="tooltip">${key}<br>Kein Backup</div></div>`);
} else {
const cls = data.has_error ? 'error' : 'ok';
days.push(`<div class="cal-day ${cls}">${dayNum}<div class="tooltip">${key}<br>${data.count}x Backup<br>${fmtBytes(data.total_size)}<br>Ø ${fmtDuration(data.avg_duration)}</div></div>`);
}
}
// Future days to fill the row
for (let i = 1; i <= 5; i++) {
const d = new Date();
d.setDate(d.getDate() + i);
days.push(`<div class="cal-day future">${d.getDate()}</div>`);
}
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 `<div class="size-bar" style="height:${h}%;${opacity}"><div class="tooltip">${d.key}<br>${fmtBytes(d.size)}</div></div>`;
}).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;
}

102
templates/index.html Normal file
View file

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backup Monitor Pfannkuchen</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>">
</head>
<body>
<!-- ── Header ──────────────────────────────────────────── -->
<header>
<div class="header-left">
<span class="logo">🛡️</span>
<h1>Backup Monitor</h1>
<span class="subtitle">Homelab Pfannkuchen</span>
</div>
<div class="header-right">
<div class="pulse-dot" id="globalPulse"></div>
<span id="lastUpdate" class="meta-text"></span>
<button class="btn btn-icon" onclick="loadAll()" title="Aktualisieren"></button>
<button class="btn btn-primary" onclick="openAddHost()">+ Host</button>
</div>
</header>
<!-- ── Summary Cards ───────────────────────────────────── -->
<section class="summary-grid" id="summaryGrid">
<div class="card summary-card" id="cardTotal">
<div class="card-value" id="valTotal"></div>
<div class="card-label">Hosts</div>
</div>
<div class="card summary-card card-ok" id="cardOk">
<div class="card-value" id="valOk"></div>
<div class="card-label">OK</div>
</div>
<div class="card summary-card card-warn" id="cardStale">
<div class="card-value" id="valStale"></div>
<div class="card-label">Überfällig</div>
</div>
<div class="card summary-card card-error" id="cardError">
<div class="card-value" id="valError"></div>
<div class="card-label">Fehler</div>
</div>
<div class="card summary-card" id="cardToday">
<div class="card-value" id="valToday"></div>
<div class="card-label">Heute</div>
</div>
<div class="card summary-card" id="cardSize">
<div class="card-value" id="valSize"></div>
<div class="card-label">Heute gesichert</div>
</div>
</section>
<!-- ── Host Grid ───────────────────────────────────────── -->
<section class="host-grid" id="hostGrid"></section>
<!-- ── Detail Drawer ───────────────────────────────────── -->
<div class="drawer-overlay" id="drawerOverlay" onclick="closeDrawer()"></div>
<aside class="drawer" id="drawer">
<div class="drawer-header">
<h2 id="drawerTitle">Host Details</h2>
<button class="btn btn-icon" onclick="closeDrawer()"></button>
</div>
<div class="drawer-body" id="drawerBody"></div>
</aside>
<!-- ── Add/Edit Host Modal ─────────────────────────────── -->
<div class="modal-overlay" id="modalOverlay" onclick="closeModal()"></div>
<div class="modal" id="modal">
<div class="modal-header">
<h2 id="modalTitle">Host hinzufügen</h2>
<button class="btn btn-icon" onclick="closeModal()"></button>
</div>
<form id="hostForm" onsubmit="saveHost(event)">
<input type="hidden" id="formMode" value="add">
<div class="form-group">
<label>Hostname</label>
<input type="text" id="formName" placeholder="z.B. arrapps" required>
</div>
<div class="form-group">
<label>Uptime Kuma Push-URL</label>
<input type="url" id="formKumaUrl" placeholder="https://status.guck.tv/api/push/borg-hostname?status=up&msg=OK">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="formEnabled" checked>
Aktiv
</label>
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="closeModal()">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
<!-- ── Toast ───────────────────────────────────────────── -->
<div class="toast-container" id="toastContainer"></div>
<script src="/static/js/app.js"></script>
</body>
</html>