DDP uses fire-and-forget UDP, so when a WiFi device becomes overwhelmed by sustained traffic, sends appear successful while the device is actually unreachable. This adds: - HTTP liveness probe (GET /json/info, 2s timeout) every 10s during streaming, exposed as device_streaming_reachable in target state - Adaptive FPS (opt-in): exponential backoff when device is unreachable, gradual recovery when it stabilizes — finds sustainable send rate - Honest health checks: removed the lie that forced device_online=true during streaming; now runs actual health checks regardless - Target editor toggle, FPS display shows effective rate when throttled, health dot reflects streaming reachability, red highlight when unreachable - Auto-backup scheduling support in settings modal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
301 lines
12 KiB
JavaScript
301 lines
12 KiB
JavaScript
/**
|
|
* 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();
|
|
loadAutoBackupSettings();
|
|
loadBackupList();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ─── Auto-Backup settings ─────────────────────────────────
|
|
|
|
export async function loadAutoBackupSettings() {
|
|
try {
|
|
const resp = await fetchWithAuth('/system/auto-backup/settings');
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
|
|
document.getElementById('auto-backup-enabled').checked = data.enabled;
|
|
document.getElementById('auto-backup-interval').value = String(data.interval_hours);
|
|
document.getElementById('auto-backup-max').value = data.max_backups;
|
|
|
|
const statusEl = document.getElementById('auto-backup-status');
|
|
if (data.last_backup_time) {
|
|
const d = new Date(data.last_backup_time);
|
|
statusEl.textContent = t('settings.auto_backup.last_backup') + ': ' + d.toLocaleString();
|
|
} else {
|
|
statusEl.textContent = t('settings.auto_backup.last_backup') + ': ' + t('settings.auto_backup.never');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load auto-backup settings:', err);
|
|
}
|
|
}
|
|
|
|
export async function saveAutoBackupSettings() {
|
|
const enabled = document.getElementById('auto-backup-enabled').checked;
|
|
const interval_hours = parseFloat(document.getElementById('auto-backup-interval').value);
|
|
const max_backups = parseInt(document.getElementById('auto-backup-max').value, 10);
|
|
|
|
try {
|
|
const resp = await fetchWithAuth('/system/auto-backup/settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ enabled, interval_hours, max_backups }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
|
}
|
|
showToast(t('settings.auto_backup.saved'), 'success');
|
|
loadAutoBackupSettings();
|
|
loadBackupList();
|
|
} catch (err) {
|
|
console.error('Failed to save auto-backup settings:', err);
|
|
showToast(t('settings.auto_backup.save_error') + ': ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ─── Saved backup list ────────────────────────────────────
|
|
|
|
export async function loadBackupList() {
|
|
const container = document.getElementById('saved-backups-list');
|
|
try {
|
|
const resp = await fetchWithAuth('/system/backups');
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
|
|
if (data.count === 0) {
|
|
container.innerHTML = `<div style="color:var(--text-muted);font-size:0.85rem;">${t('settings.saved_backups.empty')}</div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = data.backups.map(b => {
|
|
const sizeKB = (b.size_bytes / 1024).toFixed(1);
|
|
const date = new Date(b.created_at).toLocaleString();
|
|
return `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0;border-bottom:1px solid var(--border-color);font-size:0.82rem;">
|
|
<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${b.filename}">
|
|
<span>${date}</span>
|
|
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeKB} KB</span>
|
|
</div>
|
|
<button class="btn btn-icon btn-secondary" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}" style="padding:2px 6px;font-size:0.8rem;">↺</button>
|
|
<button class="btn btn-icon btn-secondary" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}" style="padding:2px 6px;font-size:0.8rem;">⬇</button>
|
|
<button class="btn btn-icon btn-secondary" onclick="deleteSavedBackup('${b.filename}')" title="${t('settings.saved_backups.delete')}" style="padding:2px 6px;font-size:0.8rem;">✕</button>
|
|
</div>`;
|
|
}).join('');
|
|
} catch (err) {
|
|
console.error('Failed to load backup list:', err);
|
|
container.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
export async function downloadSavedBackup(filename) {
|
|
try {
|
|
const resp = await fetchWithAuth(`/system/backups/${encodeURIComponent(filename)}`, { 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 a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(a.href);
|
|
} catch (err) {
|
|
console.error('Backup download failed:', err);
|
|
showToast(t('settings.backup.error') + ': ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
export async function restoreSavedBackup(filename) {
|
|
const confirmed = await showConfirm(t('settings.restore.confirm'));
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
// Download the backup file from the server
|
|
const dlResp = await fetchWithAuth(`/system/backups/${encodeURIComponent(filename)}`, { timeout: 30000 });
|
|
if (!dlResp.ok) {
|
|
const err = await dlResp.json().catch(() => ({}));
|
|
throw new Error(err.detail || `HTTP ${dlResp.status}`);
|
|
}
|
|
const blob = await dlResp.blob();
|
|
|
|
// POST it to the restore endpoint
|
|
const formData = new FormData();
|
|
formData.append('file', blob, filename);
|
|
|
|
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 from saved backup failed:', err);
|
|
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
export async function deleteSavedBackup(filename) {
|
|
const confirmed = await showConfirm(t('settings.saved_backups.delete_confirm'));
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const resp = await fetchWithAuth(`/system/backups/${encodeURIComponent(filename)}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
|
}
|
|
loadBackupList();
|
|
} catch (err) {
|
|
console.error('Backup delete failed:', err);
|
|
showToast(t('settings.saved_backups.delete_error') + ': ' + err.message, 'error');
|
|
}
|
|
}
|