From f8656b72a6c6cc03ea852fffb26284fd9cad8c44 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 26 Feb 2026 18:23:18 +0300 Subject: [PATCH] Add configuration backup/restore with settings modal Backend: GET /api/v1/system/backup bundles all 11 store JSON files into a single downloadable backup with metadata envelope. POST /api/v1/system/restore validates and writes stores atomically, then schedules a delayed server restart via detached restart.ps1 subprocess. Frontend: Settings modal (gear button in header) with Download Backup and Restore from Backup buttons. Restore shows confirm dialog, uploads via multipart FormData, then displays fullscreen restart overlay that polls /health until the server comes back and reloads the page. Locales: en, ru, zh translations for all settings.* keys. Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/api/routes/system.py | 148 +++++++++++++++++- .../src/wled_controller/api/schemas/system.py | 11 ++ server/src/wled_controller/static/js/app.js | 11 +- .../static/js/features/settings.js | 137 ++++++++++++++++ .../wled_controller/static/locales/en.json | 18 ++- .../wled_controller/static/locales/ru.json | 18 ++- .../wled_controller/static/locales/zh.json | 18 ++- .../src/wled_controller/templates/index.html | 4 + .../templates/modals/settings.html | 33 ++++ 9 files changed, 391 insertions(+), 7 deletions(-) create mode 100644 server/src/wled_controller/static/js/features/settings.js create mode 100644 server/src/wled_controller/templates/modals/settings.html diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index c098aff..3f91cd5 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -1,12 +1,17 @@ -"""System routes: health, version, displays, performance, ADB.""" +"""System routes: health, version, displays, performance, backup/restore, ADB.""" +import io +import json import subprocess import sys +import threading from datetime import datetime +from pathlib import Path from typing import Optional import psutil -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import StreamingResponse from pydantic import BaseModel from wled_controller import __version__ @@ -19,10 +24,12 @@ from wled_controller.api.schemas.system import ( HealthResponse, PerformanceResponse, ProcessListResponse, + RestoreResponse, VersionResponse, ) +from wled_controller.config import get_config from wled_controller.core.capture.screen_capture import get_available_displays -from wled_controller.utils import get_logger +from wled_controller.utils import atomic_write_json, get_logger logger = get_logger(__name__) @@ -206,6 +213,141 @@ async def get_metrics_history( return manager.metrics_history.get_history() +# --------------------------------------------------------------------------- +# Configuration backup / restore +# --------------------------------------------------------------------------- + +# Mapping: logical store name → StorageConfig attribute name +STORE_MAP = { + "devices": "devices_file", + "capture_templates": "templates_file", + "postprocessing_templates": "postprocessing_templates_file", + "picture_sources": "picture_sources_file", + "picture_targets": "picture_targets_file", + "pattern_templates": "pattern_templates_file", + "color_strip_sources": "color_strip_sources_file", + "audio_sources": "audio_sources_file", + "audio_templates": "audio_templates_file", + "value_sources": "value_sources_file", + "profiles": "profiles_file", +} + +_RESTART_SCRIPT = Path(__file__).resolve().parents[4] / "restart.ps1" + + +def _schedule_restart() -> None: + """Spawn restart.ps1 after a short delay so the HTTP response completes.""" + + def _restart(): + import time + time.sleep(1) + subprocess.Popen( + ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(_RESTART_SCRIPT)], + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, + ) + + threading.Thread(target=_restart, daemon=True).start() + + +@router.get("/api/v1/system/backup", tags=["System"]) +def backup_config(_: AuthRequired): + """Download all configuration as a single JSON backup file.""" + config = get_config() + stores = {} + for store_key, config_attr in STORE_MAP.items(): + file_path = Path(getattr(config.storage, config_attr)) + if file_path.exists(): + with open(file_path, "r", encoding="utf-8") as f: + stores[store_key] = json.load(f) + else: + stores[store_key] = {} + + backup = { + "meta": { + "format": "ledgrab-backup", + "format_version": 1, + "app_version": __version__, + "created_at": datetime.utcnow().isoformat() + "Z", + "store_count": len(stores), + }, + "stores": stores, + } + + content = json.dumps(backup, indent=2, ensure_ascii=False) + timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H%M%S") + filename = f"ledgrab-backup-{timestamp}.json" + + return StreamingResponse( + io.BytesIO(content.encode("utf-8")), + media_type="application/json", + 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(...), +): + """Upload a backup file to restore all configuration. Triggers server restart.""" + # Read and parse + try: + raw = await file.read() + if len(raw) > 10 * 1024 * 1024: # 10 MB limit + raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)") + backup = json.loads(raw) + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}") + + # Validate envelope + meta = backup.get("meta") + if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup": + raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file") + + fmt_version = meta.get("format_version", 0) + if fmt_version > 1: + raise HTTPException( + status_code=400, + detail=f"Backup format version {fmt_version} is not supported by this server version", + ) + + stores = backup.get("stores") + if not isinstance(stores, dict): + raise HTTPException(status_code=400, detail="Backup file missing 'stores' section") + + known_keys = set(STORE_MAP.keys()) + present_keys = known_keys & set(stores.keys()) + if not present_keys: + raise HTTPException(status_code=400, detail="Backup contains no recognized store data") + + for key in present_keys: + if not isinstance(stores[key], dict): + raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object") + + # Write store files atomically + config = get_config() + written = 0 + for store_key, config_attr in STORE_MAP.items(): + if store_key in stores: + file_path = Path(getattr(config.storage, config_attr)) + atomic_write_json(file_path, stores[store_key]) + written += 1 + logger.info(f"Restored store: {store_key} -> {file_path}") + + logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...") + _schedule_restart() + + missing = known_keys - present_keys + return RestoreResponse( + status="restored", + stores_written=written, + stores_total=len(STORE_MAP), + missing_stores=sorted(missing) if missing else [], + restart_scheduled=True, + message=f"Restored {written} stores. Server restarting...", + ) + + # --------------------------------------------------------------------------- # ADB helpers (for Android / scrcpy engine) # --------------------------------------------------------------------------- diff --git a/server/src/wled_controller/api/schemas/system.py b/server/src/wled_controller/api/schemas/system.py index 461729e..24fe62a 100644 --- a/server/src/wled_controller/api/schemas/system.py +++ b/server/src/wled_controller/api/schemas/system.py @@ -68,3 +68,14 @@ class PerformanceResponse(BaseModel): ram_percent: float = Field(description="RAM usage percent") gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)") timestamp: datetime = Field(description="Measurement timestamp") + + +class RestoreResponse(BaseModel): + """Response after restoring configuration backup.""" + + status: str = Field(description="Status of restore operation") + stores_written: int = Field(description="Number of stores successfully written") + stores_total: int = Field(description="Total number of known stores") + missing_stores: List[str] = Field(default_factory=list, description="Store keys not found in backup") + restart_scheduled: bool = Field(description="Whether server restart was scheduled") + message: str = Field(description="Human-readable status message") diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index b7cd178..29b74a8 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -131,10 +131,13 @@ import { showCSSCalibration, toggleCalibrationOverlay, } from './features/calibration.js'; -// Layer 6: tabs, navigation, command palette +// Layer 6: tabs, navigation, command palette, settings import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.js'; import { navigateToCard } from './core/navigation.js'; import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js'; +import { + openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected, +} from './features/settings.js'; // ─── Register all HTML onclick / onchange / onfocus globals ─── @@ -384,6 +387,12 @@ Object.assign(window, { navigateToCard, openCommandPalette, closeCommandPalette, + + // settings (backup / restore) + openSettingsModal, + closeSettingsModal, + downloadBackup, + handleRestoreFileSelected, }); // ─── Global keyboard shortcuts ─── diff --git a/server/src/wled_controller/static/js/features/settings.js b/server/src/wled_controller/static/js/features/settings.js new file mode 100644 index 0000000..522d2fb --- /dev/null +++ b/server/src/wled_controller/static/js/features/settings.js @@ -0,0 +1,137 @@ +/** + * Settings — backup / restore configuration. + */ + +import { apiKey } from '../core/state.js'; +import { API_BASE, fetchWithAuth } from '../core/api.js'; +import { Modal } from '../core/modal.js'; +import { showToast, showConfirm } from '../core/ui.js'; +import { t } from '../core/i18n.js'; + +// Simple modal (no form / no dirty check needed) +const settingsModal = new Modal('settings-modal'); + +export function openSettingsModal() { + document.getElementById('settings-error').style.display = 'none'; + settingsModal.open(); +} + +export function closeSettingsModal() { + settingsModal.forceClose(); +} + +// ─── Backup ──────────────────────────────────────────────── + +export async function downloadBackup() { + try { + const resp = await fetchWithAuth('/system/backup', { timeout: 30000 }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + const blob = await resp.blob(); + const disposition = resp.headers.get('Content-Disposition') || ''; + const match = disposition.match(/filename="(.+?)"/); + const filename = match ? match[1] : 'ledgrab-backup.json'; + + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(a.href); + + showToast(t('settings.backup.success'), 'success'); + } catch (err) { + console.error('Backup download failed:', err); + showToast(t('settings.backup.error') + ': ' + err.message, 'error'); + } +} + +// ─── Restore ─────────────────────────────────────────────── + +export async function handleRestoreFileSelected(input) { + const file = input.files[0]; + input.value = ''; + if (!file) return; + + const confirmed = await showConfirm(t('settings.restore.confirm')); + if (!confirmed) return; + + try { + const formData = new FormData(); + formData.append('file', file); + + const resp = await fetch(`${API_BASE}/system/restore`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${apiKey}` }, + body: formData, + }); + + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + + const data = await resp.json(); + showToast(data.message || t('settings.restore.success'), 'success'); + settingsModal.forceClose(); + + if (data.restart_scheduled) { + showRestartOverlay(); + } + } catch (err) { + console.error('Restore failed:', err); + showToast(t('settings.restore.error') + ': ' + err.message, 'error'); + } +} + +// ─── Restart overlay ─────────────────────────────────────── + +function showRestartOverlay() { + const overlay = document.createElement('div'); + overlay.id = 'restart-overlay'; + overlay.style.cssText = + 'position:fixed;inset:0;z-index:100000;display:flex;flex-direction:column;' + + 'align-items:center;justify-content:center;background:rgba(0,0,0,0.85);color:#fff;font-size:1.2rem;'; + overlay.innerHTML = + '
' + + `
${t('settings.restore.restarting')}
`; + + // Add spinner animation if not present + if (!document.getElementById('restart-spinner-style')) { + const style = document.createElement('style'); + style.id = 'restart-spinner-style'; + style.textContent = '@keyframes spin{to{transform:rotate(360deg)}}'; + document.head.appendChild(style); + } + + document.body.appendChild(overlay); + pollHealth(); +} + +function pollHealth() { + const start = Date.now(); + const maxWait = 30000; + const interval = 1500; + + const check = async () => { + if (Date.now() - start > maxWait) { + const msg = document.getElementById('restart-msg'); + if (msg) msg.textContent = t('settings.restore.restart_timeout'); + return; + } + try { + const resp = await fetch('/health', { signal: AbortSignal.timeout(3000) }); + if (resp.ok) { + window.location.reload(); + return; + } + } catch { /* server still down */ } + setTimeout(check, interval); + }; + // Wait a moment before first check to let the server shut down + setTimeout(check, 2000); +} diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 93e1160..0c51400 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -927,5 +927,21 @@ "search.group.pp_templates": "Post-Processing Templates", "search.group.pattern_templates": "Pattern Templates", "search.group.audio": "Audio Sources", - "search.group.value": "Value Sources" + "search.group.value": "Value Sources", + + "settings.title": "Settings", + "settings.backup.label": "Backup Configuration", + "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.", + "settings.backup.button": "Download Backup", + "settings.backup.success": "Backup downloaded successfully", + "settings.backup.error": "Backup download failed", + "settings.restore.label": "Restore Configuration", + "settings.restore.hint": "Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.", + "settings.restore.button": "Restore from Backup", + "settings.restore.confirm": "This will replace ALL configuration and restart the server. Are you sure?", + "settings.restore.success": "Configuration restored", + "settings.restore.error": "Restore failed", + "settings.restore.restarting": "Server is restarting...", + "settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.", + "settings.button.close": "Close" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 30a65ad..df210f3 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -927,5 +927,21 @@ "search.group.pp_templates": "Шаблоны постобработки", "search.group.pattern_templates": "Шаблоны паттернов", "search.group.audio": "Аудиоисточники", - "search.group.value": "Источники значений" + "search.group.value": "Источники значений", + + "settings.title": "Настройки", + "settings.backup.label": "Резервное копирование", + "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.", + "settings.backup.button": "Скачать резервную копию", + "settings.backup.success": "Резервная копия скачана", + "settings.backup.error": "Ошибка скачивания резервной копии", + "settings.restore.label": "Восстановление конфигурации", + "settings.restore.hint": "Загрузите ранее сохранённый файл резервной копии для замены всей конфигурации. Сервер перезапустится автоматически.", + "settings.restore.button": "Восстановить из копии", + "settings.restore.confirm": "Это заменит ВСЮ конфигурацию и перезапустит сервер. Вы уверены?", + "settings.restore.success": "Конфигурация восстановлена", + "settings.restore.error": "Ошибка восстановления", + "settings.restore.restarting": "Сервер перезапускается...", + "settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.", + "settings.button.close": "Закрыть" } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index a2ef2a2..a1f9485 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -927,5 +927,21 @@ "search.group.pp_templates": "后处理模板", "search.group.pattern_templates": "图案模板", "search.group.audio": "音频源", - "search.group.value": "值源" + "search.group.value": "值源", + + "settings.title": "设置", + "settings.backup.label": "备份配置", + "settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。", + "settings.backup.button": "下载备份", + "settings.backup.success": "备份下载成功", + "settings.backup.error": "备份下载失败", + "settings.restore.label": "恢复配置", + "settings.restore.hint": "上传之前下载的备份文件以替换所有配置。服务器将自动重启。", + "settings.restore.button": "从备份恢复", + "settings.restore.confirm": "这将替换所有配置并重启服务器。确定继续吗?", + "settings.restore.success": "配置已恢复", + "settings.restore.error": "恢复失败", + "settings.restore.restarting": "服务器正在重启...", + "settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。", + "settings.button.close": "关闭" } diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index c562b20..622d26c 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -34,6 +34,9 @@ + + + + + + + +