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

@@ -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);
}