Some checks failed
Lint & Test / test (push) Failing after 28s
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)
240 lines
7.3 KiB
Python
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}
|