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 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 18:23:18 +03:00
parent 9cfe628cc5
commit f8656b72a6
9 changed files with 391 additions and 7 deletions

View File

@@ -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)
# ---------------------------------------------------------------------------

View File

@@ -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")

View File

@@ -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 ───

View File

@@ -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 =
'<div class="spinner" style="width:48px;height:48px;border:4px solid rgba(255,255,255,0.3);' +
'border-top-color:#fff;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>' +
`<div id="restart-msg">${t('settings.restore.restarting')}</div>`;
// 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);
}

View File

@@ -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"
}

View File

@@ -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": "Закрыть"
}

View File

@@ -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": "关闭"
}

View File

@@ -34,6 +34,9 @@
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
<span id="theme-icon">🌙</span>
</button>
<button class="search-toggle" onclick="openSettingsModal()" data-i18n-title="settings.title" title="Settings">
&#x2699;&#xFE0F;
</button>
<select id="locale-select" onchange="changeLocale()" data-i18n-title="locale.change" title="Change language" style="padding: 4px 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-color); color: var(--text-color); font-size: 0.8rem; cursor: pointer;">
<option value="en">English</option>
<option value="ru">Русский</option>
@@ -128,6 +131,7 @@
{% include 'modals/test-audio-template.html' %}
{% include 'modals/value-source-editor.html' %}
{% include 'modals/test-value-source.html' %}
{% include 'modals/settings.html' %}
{% include 'partials/tutorial-overlay.html' %}
{% include 'partials/image-lightbox.html' %}

View File

@@ -0,0 +1,33 @@
<!-- Settings Modal -->
<div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
<div class="modal-content" style="max-width: 450px;">
<div class="modal-header">
<h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2>
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<!-- Backup section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.backup.label">Backup Configuration</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.</small>
<button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button>
</div>
<!-- Restore section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restore.label">Restore Configuration</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.restore.hint">Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.</small>
<input type="file" id="settings-restore-input" accept=".json" style="display:none" onchange="handleRestoreFileSelected(this)">
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
</div>
<div id="settings-error" class="error-message" style="display:none;"></div>
</div>
</div>
</div>