split: monitoring in 3 Stacks aufgeteilt

- monitoring: Prometheus, Exporters, InfluxDB (owns monitoring_network)
- teslamate/: TeslaMate + Grafana + Postgres + Mosquitto
- backup-monitor/: Backup-Monitor + MongoDB
- Jeder Stack unabhängig steuerbar, kein gegenseitiges Risiko
This commit is contained in:
feldjaeger 2026-04-13 09:27:14 +02:00
parent d34d24a3f8
commit 5c35a1ed36
10 changed files with 1192 additions and 94 deletions

15
.env.enc Normal file
View file

@ -0,0 +1,15 @@
TM_DB_USER=ENC[AES256_GCM,data:oDO0wGr3X8We,iv:0QHLprRYHGprqYssXr6XAxeK/+Pks4Tx7UIgddU6q/g=,tag:OFDAtVpbUdyu2MPSBAiGqw==,type:str]
TM_DB_PASS=ENC[AES256_GCM,data:8QJIAhpYVg==,iv:b51ZXnk+UhtjbczyQeFVeftwYMDwH3H0R4k6JA4HMrU=,tag:fXsa1CD85UsEh5qvVtNqWA==,type:str]
TM_DB_NAME=ENC[AES256_GCM,data:i12zyQchoiAa,iv:6t1sWpzh3JXTMkcn1iXXn6ZB65gidl1qREvCN+I0ddw=,tag:0u+TOfVmyaqQ6BZHmCMwgg==,type:str]
GRAFANA_USER=ENC[AES256_GCM,data:Y59OifM=,iv:k6IBKx7NbP/EsURrRDsg3ktDj1eVaPBTUfYVvV4wWQA=,tag:bUds1/urN8Ld6B/T6tulGw==,type:str]
GRAFANA_PW=ENC[AES256_GCM,data:yy3/Y9V4,iv:4eYOuqNZsPAp25Z+N5JU4338NkDlBVyVNsx1WJIgAEc=,tag:PxgcOslxsAtX4a7H2kK/Jw==,type:str]
FQDN_TM=ENC[AES256_GCM,data:tH0Zb7lPF+35hjKFzT7m4hjJUoY=,iv:eop9OU3wvc3yHP6OxlKgXfUMUPD2z+8oSDr4ZQ47BJw=,tag:1gGP1355cOQFqz3pdZV/pQ==,type:str]
TM_TZ=ENC[AES256_GCM,data:Q7Ved1xMYTeYCCdFkA==,iv:ab8lIJVCqDVyltYCEXyqyZ+2iOzMgwvYUie1Vq72tr8=,tag:vQw2Oh+c/5C4LydLGIhW1A==,type:str]
LETSENCRYPT_EMAIL=ENC[AES256_GCM,data:SqjA551w6MCKmGwQ2NNm4F3QyAMr,iv:/TlDZlauRW1coRfA2osoCGiSnEWgVqxjlgKtDmklDUw=,tag:t+s9RO3Tdjm+ta+80j02fA==,type:str]
ENCRYPTION_KEY=ENC[AES256_GCM,data:XnnT0HJxjfFtwCulZAKQ4Ocz0wPUipmoXo/JniDl5Cl6jSEPfqsbOwCQcnnSG0yiJg==,iv:HRSx2B1jUi2pd50lvSiawQz8XiJpPBg3pIIVwwbjcV0=,tag:42ZOXqffvdmyX0Cz8bL0MQ==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVMVAweGNuWlRBK2U2aUs1\nRVo5VENnc2ZUK2pIbG5kSmtndXFVbWJPQkJRCldZaksyOS9JS0lkR0lyUEFqTGtB\nMmdjVDg0YWo5VkhTaHVwdnVXRTIyRkkKLS0tIGtHNFkxbDZ3WGI5SEJFNHkrVkdH\nSkFqM3BnWWJaMmQrMTI4aDl0aktNV0UKgtBtdUerHifZmAvZy8+4v3YJF/WHHysh\nWSqhxFQmGLt9/GvWr6wVpzQ9RzxVb/a4klzo/MhbmJvrLWOP7MESOA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1z8gak2l4h0vpcnhtcdxmem2u9h2n54vuksk8ys82609qtzampuvqh50wdr
sops_lastmodified=2026-04-04T07:42:39Z
sops_mac=ENC[AES256_GCM,data:UUHJL94dfwfPEHrQxWB1fkzbejgikrR0mlDPkcvJrrpLtHTjKqso+wCCs61dy2ChbE/Kp2Vfy0Xl/wZOGCP6sZr5SEAyXvKfUspw8sEsp2UscPgQ9oDaLvk7mKACYHzSzUmDzy7Ic35kyBE+PcKckrt14X6Ki/dp6fK6QxiGUqQ=,iv:BEcLF6VwqO7T8uP59iuN6OKPA1L07zMC07AiVa9KLzs=,tag:miPyabX8wfMXMboz0KuChg==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.2

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

396
backup-monitor/app.py Normal file
View file

@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""
Backup Monitor Backend
MongoDB-backed backup monitoring with Web UI, Uptime Kuma, Prometheus & Webhook integration.
"""
from flask import Flask, request, jsonify, render_template, Response
from pymongo import MongoClient, DESCENDING
from datetime import datetime, timedelta
from functools import wraps
import os, time, requests, logging, threading, secrets as _secrets
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"))
# API Key Auth set API_KEY to enable, leave empty to disable (open access)
API_KEY = os.environ.get("API_KEY", "")
db = MongoClient(MONGO_URI).backup_monitor
# ── Auth Decorator ─────────────────────────────────────────────────────────
def require_api_key(f):
"""Protect write endpoints. Checks X-API-Key header or ?api_key= query param."""
@wraps(f)
def decorated(*args, **kwargs):
if not API_KEY:
return f(*args, **kwargs)
key = request.headers.get("X-API-Key") or request.args.get("api_key")
if not key or key != API_KEY:
return jsonify({"error": "Unauthorized invalid or missing API key"}), 401
return f(*args, **kwargs)
return decorated
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"])
@require_api_key
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}")
# Webhooks
if entry["status"] == "error":
_send_webhooks("error", host, entry.get("message", "Backup fehlgeschlagen"))
# Check for stale hosts
_check_stale_hosts()
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"])
@require_api_key
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"])
@require_api_key
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"])
@require_api_key
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,
})
# ── Homepage Widget API ────────────────────────────────────────────────────
@app.route("/api/homepage")
def homepage_widget():
"""Returns data in Homepage custom API widget format."""
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["last_backup"]).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_size = sum(e.get("original_size", 0) for e in db.history.find({"timestamp": {"$gte": today}}))
return jsonify({
"hosts_ok": f"{ok}/{total}",
"errors": error,
"stale": stale,
"today": _fmt_bytes(today_size),
})
# ── Prometheus Metrics ──────────────────────────────────────────────────────
@app.route("/metrics")
def prometheus_metrics():
now = datetime.utcnow()
hosts = list(db.hosts.find())
lines = [
"# HELP backup_hosts_total Total number of monitored hosts",
"# TYPE backup_hosts_total gauge",
f"backup_hosts_total {len([h for h in hosts if h.get('enabled', True)])}",
"# HELP backup_host_last_seconds Seconds since last backup",
"# TYPE backup_host_last_seconds gauge",
"# HELP backup_host_status Backup status (1=ok, 0=error, -1=stale, -2=disabled)",
"# TYPE backup_host_status gauge",
"# HELP backup_host_duration_seconds Duration of last backup",
"# TYPE backup_host_duration_seconds gauge",
"# HELP backup_host_size_bytes Original size of last backup",
"# TYPE backup_host_size_bytes gauge",
"# HELP backup_host_dedup_bytes Deduplicated size of last backup",
"# TYPE backup_host_dedup_bytes gauge",
"# HELP backup_host_files_new New files in last backup",
"# TYPE backup_host_files_new gauge",
]
for h in hosts:
name = h["name"]
labels = f'host="{name}"'
age = (now - h["last_backup"]).total_seconds() if h.get("last_backup") else 999999
if not h.get("enabled", True):
status_val = -2
elif age > STALE_HOURS * 3600:
status_val = -1
elif h.get("last_status") == "error":
status_val = 0
else:
status_val = 1
lines.append(f"backup_host_last_seconds{{{labels}}} {int(age)}")
lines.append(f"backup_host_status{{{labels}}} {status_val}")
last = db.history.find_one({"host": name}, sort=[("timestamp", DESCENDING)])
if last:
lines.append(f'backup_host_duration_seconds{{{labels}}} {last.get("duration_sec", 0)}')
lines.append(f'backup_host_size_bytes{{{labels}}} {last.get("original_size", 0)}')
lines.append(f'backup_host_dedup_bytes{{{labels}}} {last.get("deduplicated_size", 0)}')
lines.append(f'backup_host_files_new{{{labels}}} {last.get("nfiles_new", 0)}')
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_count = db.history.count_documents({"timestamp": {"$gte": today}})
today_size = sum(e.get("original_size", 0) for e in db.history.find({"timestamp": {"$gte": today}}))
lines += [
"# HELP backup_today_total Backups completed today",
"# TYPE backup_today_total gauge",
f"backup_today_total {today_count}",
"# HELP backup_today_bytes Total bytes backed up today",
"# TYPE backup_today_bytes gauge",
f"backup_today_bytes {today_size}",
]
return Response("\n".join(lines) + "\n", mimetype="text/plain; version=0.0.4")
# ── Webhooks (Notifications) ──────────────────────────────────────────────
WEBHOOK_URLS = [u.strip() for u in os.environ.get("WEBHOOK_URLS", "").split(",") if u.strip()]
WEBHOOK_EVENTS = os.environ.get("WEBHOOK_EVENTS", "error,stale").split(",")
def _send_webhooks(event, host, message):
"""Fire webhooks in background thread."""
if event not in WEBHOOK_EVENTS or not WEBHOOK_URLS:
return
payload = {
"event": event,
"host": host,
"message": message,
"timestamp": datetime.utcnow().isoformat() + "Z",
}
def _fire():
for url in WEBHOOK_URLS:
try:
requests.post(url, json=payload, timeout=10)
except Exception as e:
log.warning(f"Webhook failed ({url}): {e}")
threading.Thread(target=_fire, daemon=True).start()
# ── Stale Check (runs after each push) ────────────────────────────────────
def _check_stale_hosts():
"""Check all hosts for stale status and fire webhooks."""
now = datetime.utcnow()
for h in db.hosts.find({"enabled": True}):
if not h.get("last_backup"):
continue
age_h = (now - h["last_backup"]).total_seconds() / 3600
if age_h > STALE_HOURS and not h.get("_stale_notified"):
_send_webhooks("stale", h["name"], f"Kein Backup seit {int(age_h)}h")
db.hosts.update_one({"name": h["name"]}, {"$set": {"_stale_notified": True}})
elif age_h <= STALE_HOURS and h.get("_stale_notified"):
db.hosts.update_one({"name": h["name"]}, {"$unset": {"_stale_notified": ""}})
# ── Web UI ─────────────────────────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html", api_key_required=bool(API_KEY))
# ── 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)

View file

@ -0,0 +1,27 @@
networks:
monitoring_network:
external: true
services:
backup-monitor:
build: ../backup-monitor
container_name: backup-monitor
restart: always
ports:
- "9999:9999"
environment:
- MONGO_URI=mongodb://backup-mongo:27017
- STALE_HOURS=26
depends_on:
- backup-mongo
networks:
- monitoring_network
backup-mongo:
image: mongo:4.4
container_name: backup-mongo
restart: always
volumes:
- /app-config/backup_mongo_data:/data/db
networks:
- monitoring_network

View file

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

View file

@ -0,0 +1 @@
/* Sentinel Design System all styles via Tailwind CSS */

View file

@ -0,0 +1,360 @@
/* ── The Sentinel Backup Monitor Frontend ────────────────── */
const API = '';
let apiKey = localStorage.getItem('bm_api_key') || '';
let allHosts = [];
let currentPage = 'dashboard';
// ── Init ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadAll();
setInterval(loadAll, 30000);
});
function authHeaders() {
const h = {'Content-Type': 'application/json'};
if (apiKey) h['X-API-Key'] = apiKey;
return h;
}
async function apiFetch(url, opts = {}) {
if (!opts.headers) opts.headers = {};
if (apiKey) opts.headers['X-API-Key'] = apiKey;
const r = await fetch(url, opts);
if (r.status === 401) {
const key = prompt('🔑 API-Key eingeben:');
if (key) { apiKey = key; localStorage.setItem('bm_api_key', key); opts.headers['X-API-Key'] = key; return fetch(url, opts); }
}
return r;
}
async function loadAll() {
const [sumR, hostsR] = await Promise.all([fetch(`${API}/api/summary`), fetch(`${API}/api/hosts`)]);
const sum = await sumR.json();
allHosts = await hostsR.json();
renderDashboard(sum);
renderAlerts();
renderHostGrid();
document.getElementById('lastScan').textContent = new Date().toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit'});
// System status indicator
const ss = document.getElementById('sysStatus');
if (sum.error > 0) { ss.innerHTML = '<span class="material-symbols-outlined text-sm text-error" style="font-variation-settings:\'FILL\' 1">error</span><span class="font-headline text-xs font-medium text-error">Errors Active</span>'; }
else if (sum.stale > 0) { ss.innerHTML = '<span class="material-symbols-outlined text-sm text-tertiary" style="font-variation-settings:\'FILL\' 1">warning</span><span class="font-headline text-xs font-medium text-tertiary">Stale Hosts</span>'; }
else { ss.innerHTML = '<span class="material-symbols-outlined text-sm text-secondary" style="font-variation-settings:\'FILL\' 1">cloud_done</span><span class="font-headline text-xs font-medium text-slate-300">All Systems OK</span>'; }
}
// ── Dashboard ─────────────────────────────────────────────
function renderDashboard(sum) {
document.getElementById('mOk').textContent = `${sum.ok}/${sum.total_hosts}`;
document.getElementById('mSize').textContent = fmtBytes(sum.today_size);
document.getElementById('mWarn').textContent = sum.error + sum.stale;
const wc = document.getElementById('mWarnCard');
wc.className = 'bg-surface-container-low p-6 rounded-xl' + ((sum.error + sum.stale > 0) ? ' border border-error/20' : '');
// Latest backup
const sorted = [...allHosts].filter(h => h.last_backup).sort((a,b) => new Date(b.last_backup) - new Date(a.last_backup));
if (sorted.length) {
document.getElementById('mLatest').textContent = sorted[0].last_status === 'ok' ? 'Success' : 'Error';
document.getElementById('mLatestHost').textContent = sorted[0].name;
}
// Cluster list
const cl = document.getElementById('clusterList');
const groups = { ok: [], stale: [], error: [], disabled: [] };
allHosts.forEach(h => groups[h.status]?.push(h));
let html = '';
if (groups.error.length) { html += clusterGroup('ERRORS', groups.error, 'error'); }
if (groups.stale.length) { html += clusterGroup('STALE', groups.stale, 'tertiary'); }
if (groups.ok.length) { html += clusterGroup('OPERATIONAL', groups.ok, 'secondary'); }
if (groups.disabled.length) { html += clusterGroup('DISABLED', groups.disabled, 'outline'); }
cl.innerHTML = html;
// Live stream
renderLiveStream();
loadVolumeChart();
}
function clusterGroup(label, hosts, color) {
return `
<div class="mb-4">
<div class="flex items-center gap-2 mb-3"><div class="w-1 h-4 bg-${color} rounded-full"></div><span class="text-xs font-black uppercase tracking-widest text-${color}">${label}</span></div>
${hosts.map(h => `
<div onclick="openHost('${h.name}')" class="flex items-center justify-between px-4 py-3 rounded-lg bg-surface-container hover:bg-surface-container-high transition-all cursor-pointer mb-2">
<div>
<div class="text-sm font-bold text-white font-headline">${h.name}</div>
<div class="text-[11px] text-on-surface-variant flex items-center gap-1"><span class="material-symbols-outlined text-[12px]">schedule</span> ${h.last_backup ? timeAgo(h.last_backup) : 'Never'}</div>
</div>
<span class="px-2 py-0.5 rounded text-[10px] font-black tracking-wider ${statusChipClass(h.status)}">${h.status.toUpperCase()}</span>
</div>
`).join('')}
</div>`;
}
function renderLiveStream() {
const sorted = [...allHosts].filter(h => h.last_backup).sort((a,b) => new Date(b.last_backup) - new Date(a.last_backup)).slice(0, 8);
const ls = document.getElementById('liveStream');
ls.innerHTML = sorted.map(h => {
const t = new Date(h.last_backup).toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
const isErr = h.last_status !== 'ok';
return `
<div class="flex items-center gap-4 px-4 py-3 rounded-lg hover:bg-surface-container transition-colors ${isErr ? 'bg-error-container/5' : ''}">
<span class="text-xs font-mono text-slate-500 w-16 shrink-0">${t}</span>
<div class="w-2.5 h-2.5 rounded-full ${isErr ? 'bg-error pulse-err' : 'bg-secondary'} shrink-0"></div>
<span class="text-sm flex-1">${isErr ? '<span class="text-error font-bold">ERROR:</span> ' : ''}Backup for <span class="font-bold text-white">${h.name}</span> ${isErr ? 'failed' : 'completed successfully'}.</span>
${h.last_message ? `<span class="text-[10px] font-mono text-error/80">${h.last_message}</span>` : ''}
</div>`;
}).join('');
}
async function loadVolumeChart() {
// Aggregate daily totals from all hosts
const days = [];
for (let i = 29; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(d.toISOString().split('T')[0]); }
const dailyTotals = {};
days.forEach(d => dailyTotals[d] = 0);
// Fetch calendar for top hosts (limit to avoid too many requests)
const topHosts = allHosts.slice(0, 10);
const cals = await Promise.all(topHosts.map(h => fetch(`${API}/api/calendar/${h.name}?days=30`).then(r => r.json())));
cals.forEach(cal => { Object.entries(cal).forEach(([day, data]) => { if (dailyTotals[day] !== undefined) dailyTotals[day] += data.total_size; }); });
const values = days.map(d => dailyTotals[d]);
const max = Math.max(...values, 1);
const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${100 - (v / max) * 80}`);
const pathD = 'M' + points.join(' L');
const fillD = pathD + ` L100,100 L0,100 Z`;
document.getElementById('chartSvg').innerHTML = `
<defs><linearGradient id="cg" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#adc6ff" stop-opacity="0.2"/><stop offset="100%" stop-color="#adc6ff" stop-opacity="0"/></linearGradient></defs>
${[20,40,60,80].map(y => `<line x1="0" y1="${y}" x2="100" y2="${y}" stroke="#1e293b" stroke-width="0.3"/>`).join('')}
<path d="${fillD}" fill="url(#cg)"/>
<path d="${pathD}" fill="none" stroke="#adc6ff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
`;
document.getElementById('chartSvg').setAttribute('viewBox', '0 0 100 100');
document.getElementById('chartSvg').setAttribute('preserveAspectRatio', 'none');
}
// ── Alerts ────────────────────────────────────────────────
function renderAlerts() {
const issues = allHosts.filter(h => h.status === 'error' || h.status === 'stale');
document.getElementById('aCrit').textContent = String(allHosts.filter(h => h.status === 'error').length).padStart(2, '0');
document.getElementById('aStale').textContent = String(allHosts.filter(h => h.status === 'stale').length).padStart(2, '0');
const al = document.getElementById('alertList');
if (!issues.length) { al.innerHTML = '<div class="text-center py-16 text-on-surface-variant text-sm">No active alerts all systems operational ✓</div>'; return; }
al.innerHTML = issues.map(h => {
const isCrit = h.status === 'error';
const color = isCrit ? 'error' : 'tertiary';
const icon = isCrit ? 'error' : 'warning';
const label = isCrit ? 'CRITICAL' : 'STALE';
return `
<div class="bg-surface-container-low hover:bg-surface-container transition-all rounded-xl group">
<div class="flex flex-col md:flex-row items-start md:items-center gap-4 px-6 py-5">
<div class="w-12 h-12 rounded-full bg-${color}/10 flex items-center justify-center shrink-0 ${isCrit ? 'pulse-err' : ''}">
<span class="material-symbols-outlined text-${color}" style="font-variation-settings:'FILL' 1">${icon}</span>
</div>
<div class="flex-1">
<div class="flex items-center gap-3 mb-1">
<h3 class="text-white font-bold font-headline">${isCrit ? 'Backup Failed' : 'Backup Overdue'} ${h.name}</h3>
<span class="px-2 py-0.5 rounded text-[10px] font-black bg-${color}/10 text-${color} border border-${color}/20 tracking-wider">${label}</span>
</div>
<div class="flex items-center gap-4 text-xs text-on-surface-variant">
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">dns</span> ${h.name}</span>
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">schedule</span> ${h.last_backup ? timeAgo(h.last_backup) : 'Never'}</span>
${h.last_message ? `<span class="text-${color}/80 italic">${h.last_message}</span>` : `<span class="text-${color}/80 italic">${Math.round(h.age_hours)}h without backup</span>`}
</div>
</div>
<div class="flex items-center gap-2">
<button onclick="openHost('${h.name}')" class="bg-surface-variant hover:bg-surface-container-highest text-on-surface-variant px-5 py-2 rounded-lg text-xs font-bold transition-all">Details</button>
</div>
</div>
</div>`;
}).join('');
}
// ── Host Grid ─────────────────────────────────────────────
function renderHostGrid() {
const grid = document.getElementById('hostGrid');
grid.innerHTML = allHosts.map(h => `
<div onclick="openHost('${h.name}')" class="bg-surface-container-low hover:bg-surface-container rounded-xl p-6 cursor-pointer transition-all group relative overflow-hidden ${h.status === 'disabled' ? 'opacity-50' : ''}">
<div class="absolute top-0 left-0 w-1 h-full rounded-l-xl ${h.status === 'ok' ? 'bg-secondary' : h.status === 'error' ? 'bg-error' : h.status === 'stale' ? 'bg-tertiary' : 'bg-outline'}"></div>
<div class="flex justify-between items-start mb-4">
<div class="text-base font-bold text-white font-headline">${h.name}</div>
<span class="px-2 py-0.5 rounded text-[10px] font-black tracking-wider ${statusChipClass(h.status)}">${h.status.toUpperCase()}</span>
</div>
<div class="grid grid-cols-2 gap-3 text-xs">
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">Last Backup</span><span class="font-semibold text-white">${h.last_backup ? timeAgo(h.last_backup) : 'Never'}</span></div>
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">7d Backups</span><span class="font-semibold text-white">${h.backup_count_7d}</span></div>
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">Avg Duration</span><span class="font-semibold text-white">${fmtDuration(h.avg_duration_7d)}</span></div>
<div><span class="text-on-surface-variant block uppercase tracking-wider text-[10px] mb-0.5">7d Volume</span><span class="font-semibold text-white">${fmtBytes(h.total_size_7d)}</span></div>
</div>
</div>
`).join('');
}
// ── Host Detail Drawer ────────────────────────────────────
async function openHost(name) {
document.getElementById('drawerTitle').textContent = name;
document.getElementById('drawerBg').classList.remove('opacity-0','pointer-events-none');
document.getElementById('drawer').classList.remove('translate-x-full');
const body = document.getElementById('drawerBody');
body.innerHTML = '<div class="text-center py-12 text-on-surface-variant">Loading...</div>';
const [histR, calR] = await Promise.all([fetch(`${API}/api/history/${name}?days=30`), fetch(`${API}/api/calendar/${name}?days=30`)]);
const history = await histR.json();
const calendar = await calR.json();
const host = allHosts.find(h => h.name === name) || {};
const totalSize = history.reduce((s,e) => s + e.original_size, 0);
const avgDur = history.length ? Math.round(history.reduce((s,e) => s + e.duration_sec, 0) / history.length) : 0;
const rate = history.length ? Math.round(history.filter(e => e.status === 'ok').length / history.length * 100) : 0;
body.innerHTML = `
<!-- Stats -->
<div class="grid grid-cols-3 gap-3 mb-6">
<div class="bg-surface-container rounded-xl p-4 text-center"><div class="text-xl font-extrabold font-headline text-primary">${history.length}</div><div class="text-[10px] text-on-surface-variant uppercase tracking-wider mt-1">Backups</div></div>
<div class="bg-surface-container rounded-xl p-4 text-center"><div class="text-xl font-extrabold font-headline text-secondary">${rate}%</div><div class="text-[10px] text-on-surface-variant uppercase tracking-wider mt-1">Success</div></div>
<div class="bg-surface-container rounded-xl p-4 text-center"><div class="text-xl font-extrabold font-headline text-primary">${fmtDuration(avgDur)}</div><div class="text-[10px] text-on-surface-variant uppercase tracking-wider mt-1">Avg Duration</div></div>
</div>
<!-- Calendar -->
<h4 class="text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-3">30-Day Calendar</h4>
<div class="grid grid-cols-7 gap-1.5 mb-6">${buildCalendar(calendar)}</div>
<!-- Size Chart -->
<h4 class="text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-3">Data Volume</h4>
<div class="flex items-end gap-[2px] h-16 mb-6">${buildSizeChart(history)}</div>
<!-- History -->
<h4 class="text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-3">Recent Backups</h4>
<div class="space-y-0">
${history.slice(0, 15).map(e => `
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-surface-container transition-colors text-xs">
<span class="font-mono text-on-surface-variant w-24 shrink-0">${new Date(e.timestamp).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})}</span>
<span class="w-2 h-2 rounded-full ${e.status === 'ok' ? 'bg-secondary' : 'bg-error'} shrink-0"></span>
<span class="flex-1 font-medium">${fmtDuration(e.duration_sec)}</span>
<span class="text-on-surface-variant">${fmtBytes(e.original_size)}</span>
<span class="text-on-surface-variant">${e.nfiles_new ? `+${e.nfiles_new}` : ''}</span>
</div>
`).join('')}
</div>
<!-- Actions -->
<div class="flex gap-3 mt-8 pt-6 border-t border-outline-variant/10">
<button onclick="openEditHost('${name}')" class="bg-surface-container-high hover:bg-surface-container-highest px-5 py-2.5 rounded-lg text-xs font-bold transition-all flex items-center gap-2">
<span class="material-symbols-outlined text-sm">settings</span> Edit
</button>
<button onclick="confirmDelete('${name}')" class="hover:bg-error/10 text-error px-5 py-2.5 rounded-lg text-xs font-bold transition-all flex items-center gap-2">
<span class="material-symbols-outlined text-sm">delete</span> Delete
</button>
</div>
`;
}
function buildCalendar(cal) {
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];
const data = cal[key];
const num = d.getDate();
if (!data) { days.push(`<div class="aspect-square rounded bg-surface-container flex items-center justify-center text-[10px] text-slate-600" title="${key}: No backup">${num}</div>`); }
else {
const cls = data.has_error ? 'bg-error/20 text-error' : 'bg-secondary/20 text-secondary';
days.push(`<div class="aspect-square rounded ${cls} flex items-center justify-center text-[10px] font-bold cursor-default" title="${key}: ${data.count}x, ${fmtBytes(data.total_size)}">${num}</div>`);
}
}
return days.join('');
}
function buildSizeChart(history) {
const byDay = {};
history.forEach(e => { const d = e.timestamp.split('T')[0]; byDay[d] = (byDay[d]||0) + e.original_size; });
const days = [];
for (let i = 29; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate()-i); days.push({key:d.toISOString().split('T')[0], size: byDay[d.toISOString().split('T')[0]]||0}); }
const max = Math.max(...days.map(d=>d.size), 1);
return days.map(d => {
const h = d.size ? Math.max(6, (d.size/max)*100) : 4;
return `<div class="flex-1 rounded-t bg-primary ${d.size ? 'opacity-70 hover:opacity-100' : 'opacity-15'} transition-opacity" style="height:${h}%" title="${d.key}: ${fmtBytes(d.size)}"></div>`;
}).join('');
}
function closeDrawer() {
document.getElementById('drawerBg').classList.add('opacity-0','pointer-events-none');
document.getElementById('drawer').classList.add('translate-x-full');
}
// ── Modal ─────────────────────────────────────────────────
function openAddHost() {
document.getElementById('modalTitle').textContent = 'Add Host';
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 h = allHosts.find(x => x.name === name); if (!h) return;
document.getElementById('modalTitle').textContent = `Edit: ${name}`;
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 apiFetch(`${API}/api/hosts`, { method:'POST', headers:authHeaders(), body:JSON.stringify({name, kuma_push_url:kuma}) });
toast(`${name} added`);
} else {
await apiFetch(`${API}/api/hosts/${name}`, { method:'PUT', headers:authHeaders(), body:JSON.stringify({kuma_push_url:kuma, enabled}) });
toast(`${name} updated`);
}
closeModal(); loadAll();
}
async function confirmDelete(name) {
if (!confirm(`Delete "${name}" and all history?`)) return;
await apiFetch(`${API}/api/hosts/${name}`, { method:'DELETE', headers:authHeaders() });
toast(`${name} deleted`); closeDrawer(); loadAll();
}
function openModal() { document.getElementById('modalBg').classList.remove('opacity-0','pointer-events-none'); const m = document.getElementById('modal'); m.classList.remove('scale-95','opacity-0','pointer-events-none'); }
function closeModal() { document.getElementById('modalBg').classList.add('opacity-0','pointer-events-none'); const m = document.getElementById('modal'); m.classList.add('scale-95','opacity-0','pointer-events-none'); }
// ── Navigation ────────────────────────────────────────────
function showPage(page) {
currentPage = page;
['dashboard','alerts','hosts','config'].forEach(p => {
document.getElementById(`page-${p}`).classList.toggle('hidden', p !== page);
// Nav highlights
const nav = document.getElementById(`nav-${p}`);
const side = document.getElementById(`side-${p}`);
if (nav) { nav.className = p === page ? 'text-sm font-bold tracking-tight text-blue-400 px-3 py-1 rounded-lg font-headline' : 'text-sm font-medium tracking-tight text-slate-400 hover:bg-slate-800/50 px-3 py-1 rounded-lg transition-colors font-headline'; }
if (side) { side.className = p === page ? 'flex items-center gap-3 px-4 py-3 rounded-xl bg-blue-600/10 text-blue-400 border-r-2 border-blue-500 font-headline text-sm font-semibold' : 'flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 hover:text-slate-300 hover:bg-slate-900/80 transition-all font-headline text-sm font-semibold'; }
});
}
// ── Toast ─────────────────────────────────────────────────
function toast(msg) {
const t = document.createElement('div');
t.className = 'glass px-5 py-3 rounded-xl text-sm font-medium text-white shadow-2xl flex items-center gap-2 animate-[slideIn_0.3s_ease-out]';
t.innerHTML = `<span class="material-symbols-outlined text-secondary text-sm" style="font-variation-settings:'FILL' 1">check_circle</span> ${msg}`;
document.getElementById('toasts').appendChild(t);
setTimeout(() => t.remove(), 4000);
}
// ── Helpers ───────────────────────────────────────────────
function fmtBytes(b) { if (!b) return '0 B'; const u = ['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)+' '+u[i]; }
function fmtDuration(s) { if (!s) return ''; if (s<60) return s+'s'; if (s<3600) return Math.floor(s/60)+'m'; return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m'; }
function timeAgo(iso) { const d=(Date.now()-new Date(iso).getTime())/1000; if(d<60) return 'just now'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; }
function statusChipClass(s) { return { ok:'bg-secondary/10 text-secondary border border-secondary/20', error:'bg-error/10 text-error border border-error/20', stale:'bg-tertiary/10 text-tertiary border border-tertiary/20', disabled:'bg-outline/10 text-outline border border-outline/20' }[s] || ''; }

View file

@ -0,0 +1,295 @@
<!DOCTYPE html>
<html class="dark" lang="de">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>The Sentinel Backup Monitor</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
<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>">
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"surface-dim": "#0b1326", "surface": "#0b1326", "surface-bright": "#31394d",
"surface-container-lowest": "#060e20", "surface-container-low": "#131b2e",
"surface-container": "#171f33", "surface-container-high": "#222a3d",
"surface-container-highest": "#2d3449", "surface-variant": "#2d3449",
"on-surface": "#dae2fd", "on-surface-variant": "#c1c6d6",
"primary": "#adc6ff", "primary-container": "#0069de", "on-primary": "#002e69",
"secondary": "#4edea3", "secondary-container": "#00a572", "on-secondary": "#003824",
"tertiary": "#ffb95f", "tertiary-container": "#9a6100",
"error": "#ffb4ab", "error-container": "#93000a", "on-error": "#690005",
"outline": "#8b909f", "outline-variant": "#414753",
},
fontFamily: { headline: ["Manrope"], body: ["Inter"], label: ["Inter"] },
borderRadius: { DEFAULT: "0.25rem", lg: "0.5rem", xl: "0.75rem", full: "9999px" },
},
},
}
</script>
<style>
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
.pulse-ok { animation: pulse-g 2s infinite; }
@keyframes pulse-g { 0%{box-shadow:0 0 0 0 rgba(78,222,163,.4)} 70%{box-shadow:0 0 0 10px rgba(78,222,163,0)} 100%{box-shadow:0 0 0 0 rgba(78,222,163,0)} }
.pulse-err { animation: pulse-r 2s infinite; }
@keyframes pulse-r { 0%{box-shadow:0 0 0 0 rgba(255,180,171,.4)} 70%{box-shadow:0 0 0 10px rgba(255,180,171,0)} 100%{box-shadow:0 0 0 0 rgba(255,180,171,0)} }
.glass { background: rgba(45,52,73,.6); backdrop-filter: blur(20px); }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: #2d3449; border-radius: 3px; }
</style>
</head>
<body class="bg-surface-dim font-body text-on-surface antialiased">
<!-- ── Top Nav ──────────────────────────────────────────── -->
<nav class="fixed top-0 w-full z-50 flex justify-between items-center px-6 h-16 bg-slate-900/60 backdrop-blur-xl shadow-2xl shadow-slate-950/40">
<div class="flex items-center gap-8">
<span class="text-xl font-bold tracking-tighter text-white font-headline">The Sentinel</span>
<div class="hidden md:flex gap-1 items-center">
<a href="#" onclick="showPage('dashboard')" id="nav-dashboard" class="text-sm font-bold tracking-tight text-blue-400 px-3 py-1 rounded-lg font-headline">Dashboard</a>
<a href="#" onclick="showPage('alerts')" id="nav-alerts" class="text-sm font-medium tracking-tight text-slate-400 hover:bg-slate-800/50 px-3 py-1 rounded-lg transition-colors font-headline">Alert Center</a>
<a href="#" onclick="showPage('hosts')" id="nav-hosts" class="text-sm font-medium tracking-tight text-slate-400 hover:bg-slate-800/50 px-3 py-1 rounded-lg transition-colors font-headline">Backup Hosts</a>
</div>
</div>
<div class="flex items-center gap-4">
<div id="sysStatus" class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50">
<span class="material-symbols-outlined text-sm text-secondary" style="font-variation-settings:'FILL' 1">cloud_done</span>
<span class="font-headline text-xs font-medium text-slate-300">System OK</span>
</div>
<button onclick="loadAll()" class="material-symbols-outlined text-slate-400 hover:bg-slate-800/50 p-2 rounded-full transition-colors">refresh</button>
<button onclick="openAddHost()" class="material-symbols-outlined text-slate-400 hover:bg-slate-800/50 p-2 rounded-full transition-colors">add_circle</button>
</div>
</nav>
<!-- ── Sidebar ─────────────────────────────────────────── -->
<aside class="fixed left-0 top-0 h-full flex-col py-6 bg-slate-950 w-56 z-40 pt-20 hidden lg:flex">
<div class="px-5 mb-8">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-primary-container flex items-center justify-center">
<span class="material-symbols-outlined text-white text-lg" style="font-variation-settings:'FILL' 1">shield</span>
</div>
<div>
<div class="text-sm font-black text-white font-headline leading-tight">The Sentinel</div>
<div class="text-[10px] text-slate-500 uppercase tracking-widest font-semibold">Backup Monitor</div>
</div>
</div>
</div>
<div class="flex-1 px-3 space-y-1">
<a href="#" onclick="showPage('dashboard')" id="side-dashboard" class="flex items-center gap-3 px-4 py-3 rounded-xl bg-blue-600/10 text-blue-400 border-r-2 border-blue-500 font-headline text-sm font-semibold">
<span class="material-symbols-outlined">dashboard</span><span>Dashboard</span>
</a>
<a href="#" onclick="showPage('alerts')" id="side-alerts" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 hover:text-slate-300 hover:bg-slate-900/80 transition-all font-headline text-sm font-semibold">
<span class="material-symbols-outlined">warning</span><span>Alert Center</span>
</a>
<a href="#" onclick="showPage('hosts')" id="side-hosts" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 hover:text-slate-300 hover:bg-slate-900/80 transition-all font-headline text-sm font-semibold">
<span class="material-symbols-outlined">dns</span><span>Backup Hosts</span>
</a>
<a href="#" onclick="showPage('config')" id="side-config" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 hover:text-slate-300 hover:bg-slate-900/80 transition-all font-headline text-sm font-semibold">
<span class="material-symbols-outlined">settings</span><span>Configuration</span>
</a>
</div>
<div class="px-3 mt-auto">
<button onclick="openAddHost()" class="w-full bg-primary text-on-primary font-bold py-3 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all text-sm">
<span class="material-symbols-outlined text-sm">add</span> New Host
</button>
</div>
</aside>
<!-- ── Main Content ────────────────────────────────────── -->
<main class="lg:ml-56 pt-24 px-6 lg:px-8 pb-12 min-h-screen">
<!-- ════════ PAGE: DASHBOARD ════════ -->
<div id="page-dashboard">
<!-- Header -->
<header class="mb-10 flex flex-col md:flex-row justify-between md:items-end gap-4">
<div>
<h1 class="text-3xl font-extrabold font-headline tracking-tight text-white">Vault Overview</h1>
<p class="text-on-surface-variant mt-1">Operational command for Borgmatic backup infrastructure.</p>
</div>
<div class="flex gap-3">
<div class="flex items-center gap-2 bg-surface-container-low px-4 py-2 rounded-lg text-sm">
<span class="text-on-surface-variant">Last Scan:</span>
<span class="text-primary font-semibold" id="lastScan"></span>
</div>
<button onclick="loadAll()" class="bg-surface-container-highest px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2 hover:bg-surface-bright transition-colors">
<span class="material-symbols-outlined text-sm">refresh</span> Refresh
</button>
</div>
</header>
<!-- Metric Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 lg:gap-6 mb-8" id="metricCards">
<div class="bg-surface-container-low p-6 rounded-xl relative overflow-hidden">
<div class="absolute top-0 right-0 w-24 h-24 bg-secondary/5 rounded-full -mr-8 -mt-8 blur-2xl"></div>
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-secondary/10 rounded-lg"><span class="material-symbols-outlined text-secondary" style="font-variation-settings:'FILL' 1">check_circle</span></div>
</div>
<div class="text-4xl font-extrabold font-headline text-white mb-1" id="mOk"></div>
<div class="text-xs text-on-surface-variant font-medium uppercase tracking-wider">Hosts OK</div>
</div>
<div class="bg-surface-container-low p-6 rounded-xl relative overflow-hidden">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-primary/10 rounded-lg"><span class="material-symbols-outlined text-primary" style="font-variation-settings:'FILL' 1">database</span></div>
</div>
<div class="text-4xl font-extrabold font-headline text-white mb-1" id="mSize"></div>
<div class="text-xs text-on-surface-variant font-medium uppercase tracking-wider">Today Backed Up</div>
</div>
<div class="bg-surface-container-low p-6 rounded-xl relative overflow-hidden">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-secondary/10 rounded-lg pulse-ok"><span class="material-symbols-outlined text-secondary" style="font-variation-settings:'FILL' 1">bolt</span></div>
<div class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full bg-secondary"></div><span class="text-xs font-bold text-secondary">LIVE</span></div>
</div>
<div class="text-2xl font-bold font-headline text-white mb-1" id="mLatest"></div>
<div class="text-xs text-on-surface-variant font-medium uppercase tracking-wider">Latest Backup</div>
<div class="text-[10px] mt-2 font-mono text-slate-500" id="mLatestHost"></div>
</div>
<div class="bg-surface-container-low p-6 rounded-xl" id="mWarnCard">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-error/10 rounded-lg"><span class="material-symbols-outlined text-error" style="font-variation-settings:'FILL' 1">report_problem</span></div>
</div>
<div class="text-4xl font-extrabold font-headline text-error mb-1" id="mWarn">0</div>
<div class="text-xs text-on-surface-variant font-medium uppercase tracking-wider">Issues</div>
<button onclick="showPage('alerts')" class="mt-3 text-xs font-bold text-error flex items-center gap-1 hover:underline">
View alerts <span class="material-symbols-outlined text-xs">arrow_forward</span>
</button>
</div>
</div>
<!-- Chart + Clusters -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Volume Trend Chart -->
<div class="lg:col-span-2 bg-surface-container-low rounded-xl p-6 lg:p-8">
<div class="flex justify-between items-center mb-8">
<div>
<h3 class="text-lg font-bold font-headline text-white">Backup Volume Trends</h3>
<p class="text-sm text-on-surface-variant">Storage growth across all hosts (Last 30 Days)</p>
</div>
</div>
<div class="h-48 w-full relative" id="volumeChart">
<svg class="w-full h-full" preserveAspectRatio="none" id="chartSvg"></svg>
</div>
</div>
<!-- Host Clusters -->
<div class="bg-surface-container-low rounded-xl p-6 lg:p-8">
<h3 class="text-lg font-bold font-headline text-white mb-6">Host Status</h3>
<div class="space-y-3 max-h-[300px] overflow-y-auto" id="clusterList"></div>
</div>
</div>
<!-- Live Stream -->
<div class="bg-surface-container-low rounded-xl p-6 lg:p-8">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold font-headline text-white">Live Backup Stream</h3>
<span class="text-xs text-on-surface-variant font-mono">Auto-refresh: 30s</span>
</div>
<div class="space-y-0" id="liveStream"></div>
</div>
</div>
<!-- ════════ PAGE: ALERTS ════════ -->
<div id="page-alerts" class="hidden">
<header class="mb-10 flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h1 class="text-3xl font-extrabold tracking-tighter font-headline text-white">Alert Center</h1>
<p class="text-on-surface-variant max-w-lg">Real-time surveillance of the backup infrastructure.</p>
</div>
<div class="flex gap-3">
<div class="bg-surface-container-low px-6 py-4 rounded-xl flex flex-col gap-1 border-b-2 border-error">
<span class="text-error font-bold text-2xl" id="aCrit">0</span>
<span class="text-[10px] uppercase tracking-widest text-on-surface-variant font-bold">Errors</span>
</div>
<div class="bg-surface-container-low px-6 py-4 rounded-xl flex flex-col gap-1 border-b-2 border-tertiary">
<span class="text-tertiary font-bold text-2xl" id="aStale">0</span>
<span class="text-[10px] uppercase tracking-widest text-on-surface-variant font-bold">Stale</span>
</div>
</div>
</header>
<div class="space-y-4" id="alertList">
<div class="text-center py-12 text-on-surface-variant">No active alerts all systems operational ✓</div>
</div>
</div>
<!-- ════════ PAGE: HOSTS ════════ -->
<div id="page-hosts" class="hidden">
<header class="mb-10 flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h1 class="text-3xl font-extrabold tracking-tighter font-headline text-white">Backup Hosts</h1>
<p class="text-on-surface-variant">Manage and monitor all registered backup endpoints.</p>
</div>
<button onclick="openAddHost()" class="bg-primary text-on-primary font-bold py-3 px-6 rounded-xl flex items-center gap-2 shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all text-sm">
<span class="material-symbols-outlined text-sm">add</span> Add Host
</button>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4" id="hostGrid"></div>
</div>
<!-- ════════ PAGE: CONFIG ════════ -->
<div id="page-config" class="hidden">
<header class="mb-10">
<h1 class="text-3xl font-extrabold tracking-tighter font-headline text-white">Configuration</h1>
<p class="text-on-surface-variant mt-1">API endpoint and integration settings.</p>
</header>
<div class="bg-surface-container-low rounded-xl p-8 max-w-2xl space-y-6">
<div>
<h3 class="text-sm font-bold text-on-surface-variant uppercase tracking-wider mb-3">Push Endpoint</h3>
<code class="block bg-surface-dim px-4 py-3 rounded-lg text-primary text-sm font-mono">POST /api/push</code>
</div>
<div>
<h3 class="text-sm font-bold text-on-surface-variant uppercase tracking-wider mb-3">Prometheus Metrics</h3>
<code class="block bg-surface-dim px-4 py-3 rounded-lg text-primary text-sm font-mono">GET /metrics</code>
</div>
<div>
<h3 class="text-sm font-bold text-on-surface-variant uppercase tracking-wider mb-3">API Key</h3>
<p class="text-sm text-on-surface-variant">{{ 'Enabled set via API_KEY env var' if api_key_required else 'Disabled all endpoints open' }}</p>
</div>
</div>
</div>
</main>
<!-- ── Host Detail Drawer ──────────────────────────────── -->
<div id="drawerBg" onclick="closeDrawer()" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 opacity-0 pointer-events-none transition-opacity duration-300"></div>
<aside id="drawer" class="fixed top-0 right-0 bottom-0 w-[500px] max-w-[90vw] bg-surface-container-lowest z-[51] shadow-2xl transform translate-x-full transition-transform duration-300 flex flex-col">
<div class="flex justify-between items-center px-6 py-5 border-b border-outline-variant/10">
<h2 class="text-lg font-bold font-headline text-white" id="drawerTitle">Host</h2>
<button onclick="closeDrawer()" class="material-symbols-outlined text-slate-400 hover:text-white p-1 rounded-lg hover:bg-surface-container-high transition-colors">close</button>
</div>
<div class="flex-1 overflow-y-auto px-6 py-6" id="drawerBody"></div>
</aside>
<!-- ── Add/Edit Modal ──────────────────────────────────── -->
<div id="modalBg" onclick="closeModal()" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60] opacity-0 pointer-events-none transition-opacity duration-300"></div>
<div id="modal" class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[440px] max-w-[90vw] bg-surface-container-lowest rounded-xl z-[61] shadow-2xl scale-95 opacity-0 pointer-events-none transition-all duration-300">
<div class="flex justify-between items-center px-6 py-5 border-b border-outline-variant/10">
<h2 class="text-lg font-bold font-headline text-white" id="modalTitle">Add Host</h2>
<button onclick="closeModal()" class="material-symbols-outlined text-slate-400 hover:text-white">close</button>
</div>
<form id="hostForm" onsubmit="saveHost(event)" class="p-6 space-y-4">
<input type="hidden" id="formMode" value="add">
<div>
<label class="block text-xs text-on-surface-variant uppercase tracking-wider font-bold mb-2">Hostname</label>
<input type="text" id="formName" required class="w-full bg-surface-container-highest border-none rounded-lg px-4 py-3 text-on-surface text-sm focus:ring-2 focus:ring-primary/30" placeholder="e.g. arrapps">
</div>
<div>
<label class="block text-xs text-on-surface-variant uppercase tracking-wider font-bold mb-2">Uptime Kuma Push URL</label>
<input type="url" id="formKumaUrl" class="w-full bg-surface-container-highest border-none rounded-lg px-4 py-3 text-on-surface text-sm focus:ring-2 focus:ring-primary/30" placeholder="https://status.example.com/api/push/...">
</div>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" id="formEnabled" checked class="rounded bg-surface-container-highest border-none text-primary focus:ring-primary/30">
<span class="text-sm text-on-surface">Enabled</span>
</label>
<div class="flex justify-end gap-3 pt-4">
<button type="button" onclick="closeModal()" class="px-5 py-2.5 rounded-lg text-sm font-bold text-on-surface-variant hover:bg-surface-container-high transition-colors">Cancel</button>
<button type="submit" class="bg-primary text-on-primary px-6 py-2.5 rounded-lg text-sm font-bold hover:brightness-110 active:scale-95 transition-all">Save</button>
</div>
</form>
</div>
<!-- ── Toast ───────────────────────────────────────────── -->
<div id="toasts" class="fixed bottom-6 right-6 z-[70] flex flex-col gap-2"></div>
<script src="/static/js/app.js"></script>
</body>
</html>

View file

@ -3,89 +3,6 @@ networks:
name: monitoring_network
services:
teslamate:
image: teslamate/teslamate:latest
container_name: teslamate
restart: always
depends_on:
- teslamate_database
environment:
- DATABASE_USER=${TM_DB_USER}
- DATABASE_PASS=${TM_DB_PASS}
- DATABASE_NAME=${TM_DB_NAME}
- DATABASE_HOST=teslamate_database
- MQTT_HOST=mosquitto
- VIRTUAL_HOST=${FQDN_TM}
- CHECK_ORIGIN=true
- TZ=${TM_TZ}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
volumes:
- /app-config/teslamate_config:/opt/app/import
cap_drop:
- all
ports:
- "4000:4000"
networks:
- monitoring_network
teslamate_database:
image: postgres:17
container_name: teslamate_database
restart: always
environment:
- POSTGRES_USER=${TM_DB_USER}
- POSTGRES_PASSWORD=${TM_DB_PASS}
- POSTGRES_DB=${TM_DB_NAME}
volumes:
- /app-config/teslamate_database:/var/lib/postgresql/data
networks:
- monitoring_network
grafana:
image: teslamate/grafana:latest
container_name: grafana
restart: always
environment:
- DATABASE_USER=${TM_DB_USER}
- DATABASE_PASS=${TM_DB_PASS}
- DATABASE_NAME=${TM_DB_NAME}
- DATABASE_HOST=teslamate_database
- GRAFANA_PASSWD=${GRAFANA_PW}
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PW}
- GF_AUTH_ANONYMOUS_ENABLED=false
- GF_SERVER_DOMAIN=grafana.sascha-lutz.de
- GF_SERVER_ROOT_URL=https://grafana.sascha-lutz.de
- GF_SERVER_SERVE_FROM_SUB_PATH=false
- GF_AUTH_GENERIC_OAUTH_ENABLED=true
- GF_AUTH_GENERIC_OAUTH_NAME=Authentik
- GF_AUTH_GENERIC_OAUTH_CLIENT_ID=${GF_OAUTH_CLIENT_ID}
- GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=${GF_OAUTH_CLIENT_SECRET}
- GF_AUTH_GENERIC_OAUTH_SCOPES=openid profile email
- GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.sascha-lutz.de/application/o/authorize/
- GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.sascha-lutz.de/application/o/token/
- GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.sascha-lutz.de/application/o/userinfo/
- GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(groups[*], 'authentik Admins') && 'Admin' || 'Viewer'
- GF_AUTH_SIGNOUT_REDIRECT_URL=https://auth.sascha-lutz.de/application/o/grafana/end-session/
- GF_AUTH_GENERIC_OAUTH_AUTO_LOGIN=false
volumes:
- /app-config/grafana_data:/var/lib/grafana
ports:
- "3000:3000"
networks:
- monitoring_network
mosquitto:
image: eclipse-mosquitto:2
container_name: mosquitto
command: mosquitto -c /mosquitto-no-auth.conf
restart: always
volumes:
- /app-config/mosquitto_config:/mosquitto/config
- /app-config/mosquitto_data:/mosquitto/data
networks:
- monitoring_network
prometheus:
image: prom/prometheus
container_name: prometheus
@ -176,14 +93,3 @@ services:
- '--path.sysfs=/host/sys'
- '--collector.filesystem.ignored-mount-points=^/(sys|proc|dev|host|etc)($|/)'
restart: unless-stopped
backup-status:
build: ./backup-status
container_name: backup-status
restart: always
ports:
- "9999:9999"
volumes:
- /app-config/backup_status_data:/data
networks:
- monitoring_network

87
teslamate/compose.yaml Normal file
View file

@ -0,0 +1,87 @@
networks:
monitoring_network:
external: true
services:
teslamate:
image: teslamate/teslamate:latest
container_name: teslamate
restart: always
depends_on:
- teslamate_database
environment:
- DATABASE_USER=${TM_DB_USER}
- DATABASE_PASS=${TM_DB_PASS}
- DATABASE_NAME=${TM_DB_NAME}
- DATABASE_HOST=teslamate_database
- MQTT_HOST=mosquitto
- VIRTUAL_HOST=${FQDN_TM}
- CHECK_ORIGIN=true
- TZ=${TM_TZ}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
volumes:
- /app-config/teslamate_config:/opt/app/import
cap_drop:
- all
ports:
- "4000:4000"
networks:
- monitoring_network
teslamate_database:
image: postgres:17
container_name: teslamate_database
restart: always
environment:
- POSTGRES_USER=${TM_DB_USER}
- POSTGRES_PASSWORD=${TM_DB_PASS}
- POSTGRES_DB=${TM_DB_NAME}
volumes:
- /app-config/teslamate_database:/var/lib/postgresql/data
networks:
- monitoring_network
grafana:
image: teslamate/grafana:latest
container_name: grafana
restart: always
environment:
- DATABASE_USER=${TM_DB_USER}
- DATABASE_PASS=${TM_DB_PASS}
- DATABASE_NAME=${TM_DB_NAME}
- DATABASE_HOST=teslamate_database
- GRAFANA_PASSWD=${GRAFANA_PW}
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PW}
- GF_AUTH_ANONYMOUS_ENABLED=false
- GF_SERVER_DOMAIN=grafana.sascha-lutz.de
- GF_SERVER_ROOT_URL=https://grafana.sascha-lutz.de
- GF_SERVER_SERVE_FROM_SUB_PATH=false
- GF_AUTH_GENERIC_OAUTH_ENABLED=true
- GF_AUTH_GENERIC_OAUTH_NAME=Authentik
- GF_AUTH_GENERIC_OAUTH_CLIENT_ID=${GF_OAUTH_CLIENT_ID}
- GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=${GF_OAUTH_CLIENT_SECRET}
- GF_AUTH_GENERIC_OAUTH_SCOPES=openid profile email
- GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.sascha-lutz.de/application/o/authorize/
- GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.sascha-lutz.de/application/o/token/
- GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.sascha-lutz.de/application/o/userinfo/
- GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(groups[*], 'authentik Admins') && 'Admin' || 'Viewer'
- GF_AUTH_SIGNOUT_REDIRECT_URL=https://auth.sascha-lutz.de/application/o/grafana/end-session/
- GF_AUTH_GENERIC_OAUTH_AUTO_LOGIN=false
volumes:
- /app-config/grafana_data:/var/lib/grafana
ports:
- "3000:3000"
networks:
- monitoring_network
mosquitto:
image: eclipse-mosquitto:2
container_name: mosquitto
command: mosquitto -c /mosquitto-no-auth.conf
restart: always
volumes:
- /app-config/mosquitto_config:/mosquitto/config
- /app-config/mosquitto_data:/mosquitto/data
networks:
- monitoring_network