feat: auto WLED backup at 02:05, grouped by device with collapsible view
- Auto WLED config backup daily at 02:05 (keeps last 14 per device) - Backup page: WLED configs grouped by hostname in accordion - Each device shows backup count and last backup date - Collapsible sections to keep page compact - Readable date format (dd.mm.yyyy HH:MM:SS) for all backups - Scrollable containers for long lists Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
06253e3fef
commit
1f674b132c
69
app.py
69
app.py
@ -48,6 +48,7 @@ async def lifespan(application: FastAPI):
|
|||||||
bg_scheduler.add_job(scheduler.poll_status, "interval", seconds=interval, id="poll")
|
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")
|
bg_scheduler.add_job(scheduler.check_devices, "interval", seconds=240, id="device_check")
|
||||||
bg_scheduler.add_job(auto_backup_db, "cron", hour=2, minute=0, id="auto_backup")
|
bg_scheduler.add_job(auto_backup_db, "cron", hour=2, minute=0, id="auto_backup")
|
||||||
|
bg_scheduler.add_job(auto_backup_wled, "cron", hour=2, minute=5, id="auto_backup_wled")
|
||||||
# Initial device check (nicht blockierend)
|
# Initial device check (nicht blockierend)
|
||||||
bg_scheduler.add_job(scheduler.check_devices, "date", id="device_check_init")
|
bg_scheduler.add_job(scheduler.check_devices, "date", id="device_check_init")
|
||||||
bg_scheduler.start()
|
bg_scheduler.start()
|
||||||
@ -77,6 +78,36 @@ def auto_backup_db():
|
|||||||
logger.info("Auto-Backup erstellt: %s", dst)
|
logger.info("Auto-Backup erstellt: %s", dst)
|
||||||
|
|
||||||
|
|
||||||
|
def auto_backup_wled():
|
||||||
|
"""Backup all WLED configs. Keeps last 14 per device."""
|
||||||
|
from wled_client import get_wled_config
|
||||||
|
conn = database.get_db()
|
||||||
|
devices = conn.execute("SELECT id, hostname 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
|
||||||
|
|
||||||
|
# Keep only last 14 per device
|
||||||
|
prefix = safe_name + "_"
|
||||||
|
device_backups = sorted(
|
||||||
|
[f for f in os.listdir(WLED_BACKUP_DIR) if f.startswith(prefix)],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for old in device_backups[14:]:
|
||||||
|
os.remove(os.path.join(WLED_BACKUP_DIR, old))
|
||||||
|
|
||||||
|
logger.info("WLED Auto-Backup: %d Geraete gesichert", count)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Busylight Manager", lifespan=lifespan)
|
app = FastAPI(title="Busylight Manager", lifespan=lifespan)
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@ -512,23 +543,51 @@ async def backup_page(request: Request):
|
|||||||
if f.startswith("busylight_") and f.endswith(".db"):
|
if f.startswith("busylight_") and f.endswith(".db"):
|
||||||
path = os.path.join(BACKUP_DIR, f)
|
path = os.path.join(BACKUP_DIR, f)
|
||||||
size = os.path.getsize(path) / 1024
|
size = os.path.getsize(path) / 1024
|
||||||
db_backups.append({"name": f, "size": f"{size:.0f} KB"})
|
# Parse date from filename: busylight_YYYYMMDD_HHMMSS.db
|
||||||
|
try:
|
||||||
|
ts = f.replace("busylight_", "").replace(".db", "")
|
||||||
|
dt = datetime.strptime(ts, "%Y%m%d_%H%M%S")
|
||||||
|
date_display = dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
date_display = f
|
||||||
|
db_backups.append({"name": f, "date": date_display, "size": f"{size:.0f} KB"})
|
||||||
|
|
||||||
# WLED config backups
|
# WLED config backups grouped by device
|
||||||
wled_backups = []
|
wled_by_device = {}
|
||||||
if os.path.exists(WLED_BACKUP_DIR):
|
if os.path.exists(WLED_BACKUP_DIR):
|
||||||
for f in sorted(os.listdir(WLED_BACKUP_DIR), reverse=True):
|
for f in sorted(os.listdir(WLED_BACKUP_DIR), reverse=True):
|
||||||
if f.endswith(".json"):
|
if f.endswith(".json"):
|
||||||
path = os.path.join(WLED_BACKUP_DIR, f)
|
path = os.path.join(WLED_BACKUP_DIR, f)
|
||||||
size = os.path.getsize(path) / 1024
|
size = os.path.getsize(path) / 1024
|
||||||
wled_backups.append({"name": f, "size": f"{size:.1f} KB"})
|
# Extract device name and timestamp from filename
|
||||||
|
# Format: hostname_parts_YYYYMMDD_HHMMSS.json
|
||||||
|
parts = f.rsplit("_", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
device_key = parts[0]
|
||||||
|
date_str = parts[1]
|
||||||
|
time_str = parts[2].replace(".json", "")
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(date_str + time_str, "%Y%m%d%H%M%S")
|
||||||
|
date_display = dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
date_display = f"{date_str} {time_str}"
|
||||||
|
else:
|
||||||
|
device_key = f.replace(".json", "")
|
||||||
|
date_display = "-"
|
||||||
|
|
||||||
|
hostname = device_key.replace("_", ".")
|
||||||
|
if hostname not in wled_by_device:
|
||||||
|
wled_by_device[hostname] = []
|
||||||
|
wled_by_device[hostname].append({
|
||||||
|
"name": f, "size": f"{size:.1f} KB", "date": date_display,
|
||||||
|
})
|
||||||
|
|
||||||
conn = database.get_db()
|
conn = database.get_db()
|
||||||
devices = conn.execute("SELECT id, hostname, name FROM wled_devices ORDER BY name, hostname").fetchall()
|
devices = conn.execute("SELECT id, hostname, name FROM wled_devices ORDER BY name, hostname").fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return templates.TemplateResponse(*_ctx(request, "backup",
|
return templates.TemplateResponse(*_ctx(request, "backup",
|
||||||
db_backups=db_backups, wled_backups=wled_backups, devices=devices))
|
db_backups=db_backups, wled_by_device=wled_by_device, devices=devices))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/backup/db/download")
|
@app.get("/api/backup/db/download")
|
||||||
|
|||||||
@ -10,14 +10,12 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if request.query_params.get('wled_backed_up') %}
|
{% if request.query_params.get('wled_backed_up') %}
|
||||||
<div class="alert alert-success alert-dismissible fade show">
|
<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.
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if request.query_params.get('restored') == '1' %}
|
{% if request.query_params.get('restored') == '1' %}
|
||||||
<div class="alert alert-success alert-dismissible fade show">
|
<div class="alert alert-success alert-dismissible fade show">
|
||||||
<i class="bi bi-check-circle"></i> WLED-Config erfolgreich wiederhergestellt.
|
<i class="bi bi-check-circle"></i> WLED-Config erfolgreich wiederhergestellt.
|
||||||
@ -48,11 +46,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover table-sm mb-0">
|
||||||
<thead>
|
<thead class="sticky-top bg-white">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Datei</th>
|
<th>Datum</th>
|
||||||
<th>Groesse</th>
|
<th>Groesse</th>
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -60,15 +58,15 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for b in db_backups %}
|
{% for b in db_backups %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><small class="font-monospace">{{ b.name }}</small></td>
|
<td><small>{{ b.date }}</small></td>
|
||||||
<td>{{ b.size }}</td>
|
<td><small>{{ b.size }}</small></td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/api/backup/db/{{ b.name }}" class="btn btn-sm btn-outline-primary" title="Download">
|
<a href="/api/backup/db/{{ b.name }}" class="btn btn-sm btn-outline-primary py-0" title="Download">
|
||||||
<i class="bi bi-download"></i>
|
<i class="bi bi-download"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="post" action="/api/backup/db/{{ b.name }}/delete" class="d-inline"
|
<form method="post" action="/api/backup/db/{{ b.name }}/delete" class="d-inline"
|
||||||
onsubmit="return confirm('Backup loeschen?')">
|
onsubmit="return confirm('Backup loeschen?')">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Loeschen">
|
<button type="submit" class="btn btn-sm btn-outline-danger py-0" title="Loeschen">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -106,89 +104,115 @@
|
|||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<!-- Einzelnes Tuerschild sichern -->
|
<!-- Einzelnes Tuerschild sichern -->
|
||||||
<div class="p-3 border-bottom bg-light">
|
<div class="p-3 border-bottom bg-light">
|
||||||
<form method="post" class="d-flex gap-2 align-items-end" id="single-backup-form">
|
<form class="d-flex gap-2 align-items-end">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<label class="form-label small text-muted mb-1">Einzelnes Tuerschild sichern</label>
|
<label class="form-label small text-muted mb-1">Einzelnes Tuerschild sichern</label>
|
||||||
<select class="form-select form-select-sm" id="backup-device-select">
|
<select class="form-select form-select-sm" id="backup-device-select">
|
||||||
{% for d in devices %}
|
{% for d in devices %}
|
||||||
<option value="{{ d.id }}">{{ d.name or d.hostname }} ({{ d.hostname }})</option>
|
<option value="{{ d.id }}">{{ d.name or d.hostname }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="backupSingle()">
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="backupSingle()">
|
||||||
<i class="bi bi-download"></i>
|
<i class="bi bi-download"></i> Sichern
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<!-- Backups per Device, collapsible -->
|
||||||
<table class="table table-hover mb-0">
|
<div class="accordion accordion-flush" id="wled-backups" style="max-height: 500px; overflow-y: auto;">
|
||||||
<thead>
|
{% for hostname, backups in wled_by_device.items() %}
|
||||||
<tr>
|
<div class="accordion-item">
|
||||||
<th>Datei</th>
|
<h2 class="accordion-header">
|
||||||
<th>Groesse</th>
|
<button class="accordion-button collapsed py-2" type="button"
|
||||||
<th>Aktionen</th>
|
data-bs-toggle="collapse" data-bs-target="#wled-{{ loop.index }}">
|
||||||
</tr>
|
<div class="d-flex justify-content-between align-items-center w-100 me-2">
|
||||||
</thead>
|
<span>
|
||||||
<tbody>
|
<code class="me-2">{{ hostname }}</code>
|
||||||
{% for b in wled_backups %}
|
<span class="badge bg-secondary">{{ backups|length }}</span>
|
||||||
<tr>
|
</span>
|
||||||
<td><small class="font-monospace">{{ b.name }}</small></td>
|
<small class="text-muted">Letztes: {{ backups[0].date }}</small>
|
||||||
<td>{{ b.size }}</td>
|
</div>
|
||||||
<td>
|
</button>
|
||||||
<a href="/api/backup/wled/download/{{ b.name }}" class="btn btn-sm btn-outline-primary" title="Download">
|
</h2>
|
||||||
<i class="bi bi-download"></i>
|
<div id="wled-{{ loop.index }}" class="accordion-collapse collapse" data-bs-parent="#wled-backups">
|
||||||
</a>
|
<div class="accordion-body p-0">
|
||||||
<button class="btn btn-sm btn-outline-warning" title="Wiederherstellen"
|
<table class="table table-sm table-hover mb-0">
|
||||||
data-bs-toggle="modal" data-bs-target="#restore-{{ loop.index }}">
|
<thead>
|
||||||
<i class="bi bi-arrow-counterclockwise"></i>
|
<tr>
|
||||||
</button>
|
<th>Datum</th>
|
||||||
<form method="post" action="/api/backup/wled/delete/{{ b.name }}" class="d-inline"
|
<th>Groesse</th>
|
||||||
onsubmit="return confirm('Backup loeschen?')">
|
<th>Aktionen</th>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Loeschen">
|
</tr>
|
||||||
<i class="bi bi-trash"></i>
|
</thead>
|
||||||
</button>
|
<tbody>
|
||||||
</form>
|
{% for b in backups %}
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<td><small>{{ b.date }}</small></td>
|
||||||
<!-- Restore Modal -->
|
<td><small>{{ b.size }}</small></td>
|
||||||
<div class="modal fade" id="restore-{{ loop.index }}" tabindex="-1">
|
<td>
|
||||||
<div class="modal-dialog">
|
<a href="/api/backup/wled/download/{{ b.name }}" class="btn btn-sm btn-outline-primary py-0" title="Download">
|
||||||
<div class="modal-content">
|
<i class="bi bi-download"></i>
|
||||||
<form method="post" action="/api/backup/wled/restore/{{ b.name }}">
|
</a>
|
||||||
<div class="modal-header">
|
<button class="btn btn-sm btn-outline-warning py-0" title="Wiederherstellen"
|
||||||
<h5 class="modal-title">Config wiederherstellen</h5>
|
data-bs-toggle="modal" data-bs-target="#restore-{{ loop.parent.loop.index }}-{{ loop.index }}">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
</div>
|
</button>
|
||||||
<div class="modal-body">
|
<form method="post" action="/api/backup/wled/delete/{{ b.name }}" class="d-inline"
|
||||||
<p>Config <strong>{{ b.name }}</strong> auf welches Tuerschild uebertragen?</p>
|
onsubmit="return confirm('Backup loeschen?')">
|
||||||
<select name="device_id" class="form-select">
|
<button type="submit" class="btn btn-sm btn-outline-danger py-0" title="Loeschen">
|
||||||
{% for d in devices %}
|
<i class="bi bi-trash"></i>
|
||||||
<option value="{{ d.id }}">{{ d.name or d.hostname }} ({{ d.hostname }})</option>
|
</button>
|
||||||
{% endfor %}
|
</form>
|
||||||
</select>
|
</td>
|
||||||
<div class="alert alert-warning mt-3 mb-0">
|
</tr>
|
||||||
<small><i class="bi bi-exclamation-triangle"></i> Das Tuerschild startet nach dem Restore neu!</small>
|
<!-- Restore Modal -->
|
||||||
|
<div class="modal fade" id="restore-{{ loop.parent.loop.index }}-{{ 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><strong>{{ hostname }}</strong> vom <strong>{{ b.date }}</strong></p>
|
||||||
|
<p>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>
|
</div>
|
||||||
<div class="modal-footer">
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
{% endfor %}
|
||||||
<button type="submit" class="btn btn-warning">Wiederherstellen</button>
|
</tbody>
|
||||||
</div>
|
</table>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
{% if not wled_backups %}
|
</div>
|
||||||
<tr>
|
{% endfor %}
|
||||||
<td colspan="3" class="text-center text-muted py-3">Noch keine Config-Backups vorhanden</td>
|
|
||||||
</tr>
|
{% if not wled_by_device %}
|
||||||
{% endif %}
|
<div class="p-4 text-center text-muted">
|
||||||
</tbody>
|
Noch keine Config-Backups vorhanden
|
||||||
</table>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-footer text-muted">
|
||||||
|
<small><i class="bi bi-clock"></i> Auto-Backup taeglich um 02:05 Uhr (max. 14 pro Tuerschild)</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user