feat: backup system for portal DB and WLED configs

Portal DB:
- Download current DB button
- Manual backup creation
- Auto-backup daily at 02:00 (keeps last 14)
- Download/delete individual backups

WLED Config:
- Backup all devices at once
- Backup individual device
- Download config as JSON
- Restore config to any device
- Delete old backups

New sidebar menu item 'Backup' with dedicated page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Mueller 2026-04-24 12:55:06 +02:00
parent 4b6c20e638
commit 06253e3fef
5 changed files with 414 additions and 3 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@ busylight.db
config.cfg
config.dev.cfg
*.egg-info/
backups/
dist/
build/
.vscode/

179
app.py
View File

@ -1,12 +1,16 @@
#!/usr/bin/env python3
"""Busylight WLED Controller - Web Application."""
import json
import logging
import os
import shutil
import sys
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import FastAPI, Request, Form, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from apscheduler.schedulers.background import BackgroundScheduler
@ -35,11 +39,16 @@ async def lifespan(application: FastAPI):
except Exception as e:
logger.error("Graph-Client konnte nicht initialisiert werden: %s", e)
# Backup-Verzeichnis erstellen
os.makedirs(BACKUP_DIR, exist_ok=True)
os.makedirs(WLED_BACKUP_DIR, exist_ok=True)
# Start background polling
interval = int(database.get_setting("poll_interval", "15"))
bg_scheduler.add_job(scheduler.poll_status, "interval", seconds=interval, id="poll")
bg_scheduler.add_job(scheduler.check_devices, "interval", seconds=240, id="device_check")
# Initial device check nach 5 Sekunden (nicht blockierend)
bg_scheduler.add_job(auto_backup_db, "cron", hour=2, minute=0, id="auto_backup")
# Initial device check (nicht blockierend)
bg_scheduler.add_job(scheduler.check_devices, "date", id="device_check_init")
bg_scheduler.start()
logger.info("Polling gestartet (Intervall: %ds)", interval)
@ -49,6 +58,25 @@ async def lifespan(application: FastAPI):
bg_scheduler.shutdown()
BACKUP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backups")
WLED_BACKUP_DIR = os.path.join(BACKUP_DIR, "wled")
def auto_backup_db():
"""Create daily DB backup. Keeps last 14 backups."""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
dst = os.path.join(BACKUP_DIR, f"busylight_{ts}.db")
shutil.copy2(database.DB_PATH, dst)
# Keep only last 14
backups = sorted(
[f for f in os.listdir(BACKUP_DIR) if f.startswith("busylight_") and f.endswith(".db")],
reverse=True,
)
for old in backups[14:]:
os.remove(os.path.join(BACKUP_DIR, old))
logger.info("Auto-Backup erstellt: %s", dst)
app = FastAPI(title="Busylight Manager", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
@ -473,6 +501,151 @@ async def trigger_poll():
return RedirectResponse("/", status_code=303)
# ── Backup ─────────────────────────────────────────────────────────────────
@app.get("/backup", response_class=HTMLResponse)
async def backup_page(request: Request):
# Portal backups
db_backups = []
if os.path.exists(BACKUP_DIR):
for f in sorted(os.listdir(BACKUP_DIR), reverse=True):
if f.startswith("busylight_") and f.endswith(".db"):
path = os.path.join(BACKUP_DIR, f)
size = os.path.getsize(path) / 1024
db_backups.append({"name": f, "size": f"{size:.0f} KB"})
# WLED config backups
wled_backups = []
if os.path.exists(WLED_BACKUP_DIR):
for f in sorted(os.listdir(WLED_BACKUP_DIR), reverse=True):
if f.endswith(".json"):
path = os.path.join(WLED_BACKUP_DIR, f)
size = os.path.getsize(path) / 1024
wled_backups.append({"name": f, "size": f"{size:.1f} KB"})
conn = database.get_db()
devices = conn.execute("SELECT id, hostname, name FROM wled_devices ORDER BY name, hostname").fetchall()
conn.close()
return templates.TemplateResponse(*_ctx(request, "backup",
db_backups=db_backups, wled_backups=wled_backups, devices=devices))
@app.get("/api/backup/db/download")
async def download_db():
"""Download current database."""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return FileResponse(
database.DB_PATH,
media_type="application/octet-stream",
filename=f"busylight_{ts}.db",
)
@app.post("/api/backup/db/create")
async def create_db_backup():
"""Create a manual DB backup."""
auto_backup_db()
return RedirectResponse("/backup?db_created=1", status_code=303)
@app.get("/api/backup/db/{filename}")
async def download_db_backup(filename: str):
"""Download a specific DB backup."""
path = os.path.join(BACKUP_DIR, filename)
if not os.path.exists(path) or ".." in filename:
return RedirectResponse("/backup", status_code=303)
return FileResponse(path, media_type="application/octet-stream", filename=filename)
@app.post("/api/backup/db/{filename}/delete")
async def delete_db_backup(filename: str):
path = os.path.join(BACKUP_DIR, filename)
if os.path.exists(path) and ".." not in filename:
os.remove(path)
return RedirectResponse("/backup", status_code=303)
@app.post("/api/backup/wled/all")
async def backup_all_wled():
"""Backup config of all WLED devices."""
from wled_client import get_wled_config
conn = database.get_db()
devices = conn.execute("SELECT id, hostname, name FROM wled_devices").fetchall()
conn.close()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
count = 0
for dev in devices:
config = get_wled_config(dev["hostname"])
if config:
safe_name = dev["hostname"].replace(".", "_")
path = os.path.join(WLED_BACKUP_DIR, f"{safe_name}_{ts}.json")
with open(path, "w") as f:
json.dump(config, f, indent=2)
count += 1
return RedirectResponse(f"/backup?wled_backed_up={count}", status_code=303)
@app.post("/api/backup/wled/{device_id}")
async def backup_single_wled(device_id: int):
"""Backup config of a single WLED device."""
from wled_client import get_wled_config
conn = database.get_db()
dev = conn.execute("SELECT hostname FROM wled_devices WHERE id=?", (device_id,)).fetchone()
conn.close()
if not dev:
return RedirectResponse("/backup", status_code=303)
config = get_wled_config(dev["hostname"])
if config:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = dev["hostname"].replace(".", "_")
path = os.path.join(WLED_BACKUP_DIR, f"{safe_name}_{ts}.json")
with open(path, "w") as f:
json.dump(config, f, indent=2)
return RedirectResponse("/backup?wled_backed_up=1", status_code=303)
@app.get("/api/backup/wled/download/{filename}")
async def download_wled_backup(filename: str):
path = os.path.join(WLED_BACKUP_DIR, filename)
if not os.path.exists(path) or ".." in filename:
return RedirectResponse("/backup", status_code=303)
return FileResponse(path, media_type="application/json", filename=filename)
@app.post("/api/backup/wled/delete/{filename}")
async def delete_wled_backup(filename: str):
path = os.path.join(WLED_BACKUP_DIR, filename)
if os.path.exists(path) and ".." not in filename:
os.remove(path)
return RedirectResponse("/backup", status_code=303)
@app.post("/api/backup/wled/restore/{filename}")
async def restore_wled_backup(filename: str, device_id: int = Form(...)):
"""Restore a WLED config backup to a device."""
from wled_client import restore_wled_config
path = os.path.join(WLED_BACKUP_DIR, filename)
if not os.path.exists(path) or ".." in filename:
return RedirectResponse("/backup", status_code=303)
conn = database.get_db()
dev = conn.execute("SELECT hostname FROM wled_devices WHERE id=?", (device_id,)).fetchone()
conn.close()
if not dev:
return RedirectResponse("/backup", status_code=303)
with open(path) as f:
config = json.load(f)
success, msg = restore_wled_config(dev["hostname"], config)
return RedirectResponse(f"/backup?restored={'1' if success else '0'}", status_code=303)
# ── CLI Auth Command ───────────────────────────────────────────────────────
def cli_auth():

208
templates/backup.html Normal file
View File

@ -0,0 +1,208 @@
{% extends "base.html" %}
{% block title %}Backup - Busylight{% endblock %}
{% block content %}
<h4 class="mb-4">Backup</h4>
{% if request.query_params.get('db_created') %}
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> Portal-Backup erstellt.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
{% if request.query_params.get('wled_backed_up') %}
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> {{ request.query_params.get('wled_backed_up') }} Tuerschild-Config(s) gesichert.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
{% if request.query_params.get('restored') == '1' %}
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> WLED-Config erfolgreich wiederhergestellt.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% elif request.query_params.get('restored') == '0' %}
<div class="alert alert-danger alert-dismissible fade show">
<i class="bi bi-x-circle"></i> WLED-Config konnte nicht wiederhergestellt werden.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<div class="row g-4">
<!-- Portal DB Backup -->
<div class="col-lg-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-database"></i> Portal-Datenbank</h6>
<div>
<a href="/api/backup/db/download" class="btn btn-sm btn-outline-primary">
<i class="bi bi-download"></i> Aktuelle DB
</a>
<form method="post" action="/api/backup/db/create" class="d-inline">
<button type="submit" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle"></i> Backup erstellen
</button>
</form>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Datei</th>
<th>Groesse</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for b in db_backups %}
<tr>
<td><small class="font-monospace">{{ b.name }}</small></td>
<td>{{ b.size }}</td>
<td>
<a href="/api/backup/db/{{ b.name }}" class="btn btn-sm btn-outline-primary" title="Download">
<i class="bi bi-download"></i>
</a>
<form method="post" action="/api/backup/db/{{ b.name }}/delete" class="d-inline"
onsubmit="return confirm('Backup loeschen?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Loeschen">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
{% if not db_backups %}
<tr>
<td colspan="3" class="text-center text-muted py-3">Noch keine Backups vorhanden</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="card-footer text-muted">
<small><i class="bi bi-clock"></i> Auto-Backup taeglich um 02:00 Uhr (max. 14 Backups)</small>
</div>
</div>
</div>
<!-- WLED Config Backup -->
<div class="col-lg-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-router"></i> Tuerschild-Konfigurationen</h6>
<div>
<form method="post" action="/api/backup/wled/all" class="d-inline">
<button type="submit" class="btn btn-sm btn-primary">
<i class="bi bi-download"></i> Alle sichern
</button>
</form>
</div>
</div>
<div class="card-body p-0">
<!-- Einzelnes Tuerschild sichern -->
<div class="p-3 border-bottom bg-light">
<form method="post" class="d-flex gap-2 align-items-end" id="single-backup-form">
<div class="flex-grow-1">
<label class="form-label small text-muted mb-1">Einzelnes Tuerschild sichern</label>
<select class="form-select form-select-sm" id="backup-device-select">
{% for d in devices %}
<option value="{{ d.id }}">{{ d.name or d.hostname }} ({{ d.hostname }})</option>
{% endfor %}
</select>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="backupSingle()">
<i class="bi bi-download"></i>
</button>
</form>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Datei</th>
<th>Groesse</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for b in wled_backups %}
<tr>
<td><small class="font-monospace">{{ b.name }}</small></td>
<td>{{ b.size }}</td>
<td>
<a href="/api/backup/wled/download/{{ b.name }}" class="btn btn-sm btn-outline-primary" title="Download">
<i class="bi bi-download"></i>
</a>
<button class="btn btn-sm btn-outline-warning" title="Wiederherstellen"
data-bs-toggle="modal" data-bs-target="#restore-{{ loop.index }}">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
<form method="post" action="/api/backup/wled/delete/{{ b.name }}" class="d-inline"
onsubmit="return confirm('Backup loeschen?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Loeschen">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
<!-- Restore Modal -->
<div class="modal fade" id="restore-{{ loop.index }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/api/backup/wled/restore/{{ b.name }}">
<div class="modal-header">
<h5 class="modal-title">Config wiederherstellen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Config <strong>{{ b.name }}</strong> auf welches Tuerschild uebertragen?</p>
<select name="device_id" class="form-select">
{% for d in devices %}
<option value="{{ d.id }}">{{ d.name or d.hostname }} ({{ d.hostname }})</option>
{% endfor %}
</select>
<div class="alert alert-warning mt-3 mb-0">
<small><i class="bi bi-exclamation-triangle"></i> Das Tuerschild startet nach dem Restore neu!</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-warning">Wiederherstellen</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
{% if not wled_backups %}
<tr>
<td colspan="3" class="text-center text-muted py-3">Noch keine Config-Backups vorhanden</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function backupSingle() {
const id = document.getElementById('backup-device-select').value;
const form = document.createElement('form');
form.method = 'POST';
form.action = '/api/backup/wled/' + id;
document.body.appendChild(form);
form.submit();
}
</script>
{% endblock %}

View File

@ -42,6 +42,11 @@
<i class="bi bi-palette"></i> Farb-Regeln
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'backup' %}active{% endif %}" href="/backup">
<i class="bi bi-download"></i> Backup
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'settings' %}active{% endif %}" href="/settings">
<i class="bi bi-gear"></i> Einstellungen

View File

@ -45,3 +45,27 @@ def get_wled_info(hostname):
return {"online": False}
except requests.exceptions.RequestException:
return {"online": False}
def get_wled_config(hostname):
"""Download full WLED config as JSON."""
try:
r = requests.get(f"http://{hostname}/cfg.json", timeout=TIMEOUT)
if r.status_code == 200:
return r.json()
return None
except requests.exceptions.RequestException:
return None
def restore_wled_config(hostname, config_json):
"""Push config JSON to WLED device."""
try:
r = requests.post(
f"http://{hostname}/json/cfg",
json=config_json,
timeout=TIMEOUT,
)
return r.status_code == 200, f"{r.status_code} {r.reason}"
except requests.exceptions.RequestException as e:
return False, str(e)