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:
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
137
server/src/wled_controller/static/js/features/settings.js
Normal file
137
server/src/wled_controller/static/js/features/settings.js
Normal 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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Закрыть"
|
||||
}
|
||||
|
||||
@@ -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": "关闭"
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
⚙️
|
||||
</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' %}
|
||||
|
||||
33
server/src/wled_controller/templates/modals/settings.html
Normal file
33
server/src/wled_controller/templates/modals/settings.html
Normal 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">✕</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>
|
||||
Reference in New Issue
Block a user