Files
wled-screen-controller-mixed/server/src/wled_controller/api/routes/backup.py
alexei.dolgolyov 9dfd2365f4
Some checks failed
Lint & Test / test (push) Failing after 28s
feat: migrate storage from JSON files to SQLite
Replace 22 individual JSON store files with a single SQLite database
(data/ledgrab.db). All entity stores now use BaseSqliteStore backed by
SQLite with WAL mode, write-through caching, and thread-safe access.

- Add Database class with SQLite backup/restore API
- Add BaseSqliteStore as drop-in replacement for BaseJsonStore
- Convert all 16 entity stores to SQLite
- Move global settings (MQTT, external URL, auto-backup) to SQLite
  settings table
- Replace JSON backup/restore with SQLite snapshot backups (.db files)
- Remove partial export/import feature (backend + frontend)
- Update demo seed to write directly to SQLite
- Add "Backup Now" button to settings UI
- Remove StorageConfig file path fields (single database_file remains)
2026-03-25 00:03:19 +03:00

240 lines
7.3 KiB
Python

"""System routes: backup, restore, auto-backup.
All backups are SQLite database snapshots (.db files).
"""
import asyncio
import io
import subprocess
import sys
import threading
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_auto_backup_engine, get_database
from wled_controller.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
BackupFileInfo,
BackupListResponse,
RestoreResponse,
)
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.storage.database import Database, freeze_writes
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
_SERVER_DIR = Path(__file__).resolve().parents[4]
def _schedule_restart() -> None:
"""Spawn a restart script after a short delay so the HTTP response completes."""
def _restart():
import time
time.sleep(1)
if sys.platform == "win32":
subprocess.Popen(
["powershell", "-ExecutionPolicy", "Bypass", "-File",
str(_SERVER_DIR / "restart.ps1")],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
subprocess.Popen(
["bash", str(_SERVER_DIR / "restart.sh")],
start_new_session=True,
)
threading.Thread(target=_restart, daemon=True).start()
# ---------------------------------------------------------------------------
# Backup / restore (SQLite snapshots)
# ---------------------------------------------------------------------------
@router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired, db: Database = Depends(get_database)):
"""Download a full database backup as a .db file."""
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
db.backup_to(tmp_path)
content = tmp_path.read_bytes()
finally:
tmp_path.unlink(missing_ok=True)
from datetime import datetime, timezone
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.db"
return StreamingResponse(
io.BytesIO(content),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
async def restore_config(
_: AuthRequired,
file: UploadFile = File(...),
db: Database = Depends(get_database),
):
"""Upload a .db backup file to restore all configuration. Triggers server restart."""
raw = await file.read()
if len(raw) > 50 * 1024 * 1024: # 50 MB limit
raise HTTPException(status_code=400, detail="Backup file too large (max 50 MB)")
if len(raw) < 100:
raise HTTPException(status_code=400, detail="File too small to be a valid SQLite database")
# SQLite files start with "SQLite format 3\000"
if not raw[:16].startswith(b"SQLite format 3"):
raise HTTPException(status_code=400, detail="Not a valid SQLite database file")
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp.write(raw)
tmp_path = Path(tmp.name)
try:
def _restore():
db.restore_from(tmp_path)
await asyncio.to_thread(_restore)
finally:
tmp_path.unlink(missing_ok=True)
freeze_writes()
logger.info("Database restored from uploaded backup. Scheduling restart...")
_schedule_restart()
return RestoreResponse(
status="restored",
restart_scheduled=True,
message="Database restored from backup. Server restarting...",
)
@router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
from wled_controller.server_ref import _broadcast_restarting
_broadcast_restarting()
_schedule_restart()
return {"status": "restarting"}
@router.post("/api/v1/system/shutdown", tags=["System"])
def shutdown_server(_: AuthRequired):
"""Gracefully shut down the server."""
from wled_controller.server_ref import request_shutdown
request_shutdown()
return {"status": "shutting_down"}
# ---------------------------------------------------------------------------
# Auto-backup settings & saved backups
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def get_auto_backup_settings(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Get auto-backup settings and status."""
return engine.get_settings()
@router.put(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def update_auto_backup_settings(
_: AuthRequired,
body: AutoBackupSettings,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings(
enabled=body.enabled,
interval_hours=body.interval_hours,
max_backups=body.max_backups,
)
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
async def trigger_backup(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Manually trigger a backup now."""
backup = await engine.trigger_backup()
return {"status": "ok", "backup": backup}
@router.get(
"/api/v1/system/backups",
response_model=BackupListResponse,
tags=["System"],
)
async def list_backups(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""List all saved backup files."""
backups = engine.list_backups()
return BackupListResponse(
backups=[BackupFileInfo(**b) for b in backups],
count=len(backups),
)
@router.get("/api/v1/system/backups/{filename}", tags=["System"])
def download_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Download a specific saved backup file."""
try:
path = engine.get_backup_path(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
content = path.read_bytes()
return StreamingResponse(
io.BytesIO(content),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete("/api/v1/system/backups/{filename}", tags=["System"])
async def delete_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Delete a specific saved backup file."""
try:
engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "deleted", "filename": filename}