Initial release – Backup Monitor with MongoDB, Dark Theme UI, Borgmatic + Uptime Kuma integration
This commit is contained in:
commit
e2023abee5
10 changed files with 1378 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
mongo_data/
|
||||
7
Dockerfile
Normal file
7
Dockerfile
Normal 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
21
LICENSE
Normal 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
164
README.md
Normal 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).
|
||||
|
||||
   
|
||||
|
||||
## 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
238
app.py
Normal 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
22
compose.yaml
Normal 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
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Flask==3.1.*
|
||||
pymongo==4.12.*
|
||||
requests==2.32.*
|
||||
gunicorn==23.*
|
||||
486
static/css/style.css
Normal file
486
static/css/style.css
Normal 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
328
static/js/app.js
Normal 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
102
templates/index.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue