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:
@@ -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": "关闭"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user