/** * 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'; import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js'; import { IconSelect } from '../core/icon-select.js'; // ─── Log Viewer ──────────────────────────────────────────── /** @type {WebSocket|null} */ let _logWs = null; /** Level ordering for filter comparisons */ const _LOG_LEVELS = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 }; function _detectLevel(line) { for (const lvl of ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']) { if (line.includes(lvl)) return lvl; } return 'DEBUG'; } function _levelClass(level) { if (level === 'ERROR' || level === 'CRITICAL') return 'log-line-error'; if (level === 'WARNING') return 'log-line-warning'; if (level === 'DEBUG') return 'log-line-debug'; return ''; } function _filterLevel() { const sel = document.getElementById('log-viewer-filter'); return sel ? sel.value : 'all'; } function _linePassesFilter(line) { const filter = _filterLevel(); if (filter === 'all') return true; const lineLvl = _detectLevel(line); return (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0); } function _appendLine(line) { // Skip keepalive empty pings if (!line) return; if (!_linePassesFilter(line)) return; const output = document.getElementById('log-viewer-output'); if (!output) return; const level = _detectLevel(line); const cls = _levelClass(level); const span = document.createElement('span'); if (cls) span.className = cls; span.textContent = line + '\n'; output.appendChild(span); // Auto-scroll to bottom output.scrollTop = output.scrollHeight; } export function connectLogViewer() { const btn = document.getElementById('log-viewer-connect-btn'); if (_logWs && (_logWs.readyState === WebSocket.OPEN || _logWs.readyState === WebSocket.CONNECTING)) { // Disconnect _logWs.close(); _logWs = null; if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; } return; } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${proto}//${location.host}/api/v1/system/logs/ws?token=${encodeURIComponent(apiKey)}`; _logWs = new WebSocket(url); _logWs.onopen = () => { if (btn) { btn.textContent = t('settings.logs.disconnect'); btn.dataset.i18n = 'settings.logs.disconnect'; } }; _logWs.onmessage = (evt) => { _appendLine(evt.data); }; _logWs.onerror = () => { showToast(t('settings.logs.error'), 'error'); }; _logWs.onclose = () => { _logWs = null; if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; } }; } export function disconnectLogViewer() { if (_logWs) { _logWs.close(); _logWs = null; } const btn = document.getElementById('log-viewer-connect-btn'); if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; } } export function clearLogViewer() { const output = document.getElementById('log-viewer-output'); if (output) output.innerHTML = ''; } /** Re-render the log output according to the current filter selection. */ export function applyLogFilter() { // We don't buffer all raw lines in JS — just clear and note the filter // will apply to future lines. Existing lines that were already rendered // are re-evaluated by toggling their visibility. const output = document.getElementById('log-viewer-output'); if (!output) return; const filter = _filterLevel(); for (const span of output.children) { const line = span.textContent; const lineLvl = _detectLevel(line); const passes = filter === 'all' || (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0); span.style.display = passes ? '' : 'none'; } } // Simple modal (no form / no dirty check needed) const settingsModal = new Modal('settings-modal'); let _logFilterIconSelect = null; let _logLevelIconSelect = null; const _LOG_LEVEL_ITEMS = [ { value: 'DEBUG', icon: 'D', label: 'DEBUG', desc: t('settings.log_level.desc.debug') }, { value: 'INFO', icon: 'I', label: 'INFO', desc: t('settings.log_level.desc.info') }, { value: 'WARNING', icon: 'W', label: 'WARNING', desc: t('settings.log_level.desc.warning') }, { value: 'ERROR', icon: 'E', label: 'ERROR', desc: t('settings.log_level.desc.error') }, { value: 'CRITICAL', icon: '!', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') }, ]; const _LOG_FILTER_ITEMS = [ { value: 'all', icon: '*', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') }, { value: 'INFO', icon: 'I', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') }, { value: 'WARNING', icon: 'W', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') }, { value: 'ERROR', icon: 'E', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') }, ]; export function openSettingsModal() { document.getElementById('settings-error').style.display = 'none'; settingsModal.open(); // Initialize log filter icon select if (!_logFilterIconSelect) { const filterSel = document.getElementById('log-viewer-filter'); if (filterSel) { _logFilterIconSelect = new IconSelect({ target: filterSel, items: _LOG_FILTER_ITEMS, columns: 2, onChange: () => applyLogFilter(), }); } } // Initialize log level icon select if (!_logLevelIconSelect) { const levelSel = document.getElementById('settings-log-level'); if (levelSel) { _logLevelIconSelect = new IconSelect({ target: levelSel, items: _LOG_LEVEL_ITEMS, columns: 3, onChange: () => setLogLevel(), }); } } loadApiKeysList(); loadAutoBackupSettings(); loadBackupList(); loadMqttSettings(); loadLogLevel(); } export function closeSettingsModal() { disconnectLogViewer(); 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'); } } // ─── Server restart ──────────────────────────────────────── export async function restartServer() { const confirmed = await showConfirm(t('settings.restart_confirm')); if (!confirmed) return; try { const resp = await fetchWithAuth('/system/restart', { method: 'POST' }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } settingsModal.forceClose(); showRestartOverlay(t('settings.restarting')); } catch (err) { console.error('Server restart failed:', err); showToast(t('settings.restore.error') + ': ' + err.message, 'error'); } } // ─── Restart overlay ─────────────────────────────────────── function showRestartOverlay(message) { const msg = message || t('settings.restore.restarting'); 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 = '
' + `
${msg}
`; // 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 = `
${t('settings.saved_backups.empty')}
`; return; } container.innerHTML = data.backups.map(b => { const sizeBytes = b.size_bytes || 0; const sizeStr = sizeBytes >= 1024 * 1024 ? (sizeBytes / (1024 * 1024)).toFixed(1) + ' MB' : (sizeBytes / 1024).toFixed(1) + ' KB'; const date = new Date(b.created_at).toLocaleString(); const isAuto = b.filename.startsWith('ledgrab-autobackup-'); const typeBadge = isAuto ? `${t('settings.saved_backups.type.auto')}` : `${t('settings.saved_backups.type.manual')}`; return `
${typeBadge}
${date} ${sizeStr}
`; }).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'); } } // ─── API Keys (read-only display) ───────────────────────────── export async function loadApiKeysList() { const container = document.getElementById('settings-api-keys-list'); if (!container) return; try { const resp = await fetchWithAuth('/system/api-keys'); if (!resp.ok) { container.innerHTML = `
${t('settings.api_keys.load_error')}
`; return; } const data = await resp.json(); if (data.count === 0) { container.innerHTML = `
${t('settings.api_keys.empty')}
`; return; } container.innerHTML = data.keys.map(k => `
${k.label} ${k.masked}
` ).join(''); } catch (err) { console.error('Failed to load API keys:', err); if (container) container.innerHTML = ''; } } // ─── Partial Export / Import ─────────────────────────────────── export async function downloadPartialExport() { const storeKey = document.getElementById('settings-partial-store').value; try { const resp = await fetchWithAuth(`/system/export/${encodeURIComponent(storeKey)}`, { 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-${storeKey}.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.partial.export_success'), 'success'); } catch (err) { console.error('Partial export failed:', err); showToast(t('settings.partial.export_error') + ': ' + err.message, 'error'); } } export async function handlePartialImportFileSelected(input) { const file = input.files[0]; input.value = ''; if (!file) return; const storeKey = document.getElementById('settings-partial-store').value; const merge = document.getElementById('settings-partial-merge').checked; const confirmMsg = merge ? t('settings.partial.import_confirm_merge').replace('{store}', storeKey) : t('settings.partial.import_confirm_replace').replace('{store}', storeKey); const confirmed = await showConfirm(confirmMsg); if (!confirmed) return; try { const formData = new FormData(); formData.append('file', file); const url = `${API_BASE}/system/import/${encodeURIComponent(storeKey)}?merge=${merge}`; const resp = await fetch(url, { 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.partial.import_success'), 'success'); settingsModal.forceClose(); if (data.restart_scheduled) { showRestartOverlay(); } } catch (err) { console.error('Partial import failed:', err); showToast(t('settings.partial.import_error') + ': ' + err.message, 'error'); } } // ─── Log Level ──────────────────────────────────────────────── export async function loadLogLevel() { try { const resp = await fetchWithAuth('/system/log-level'); if (!resp.ok) return; const data = await resp.json(); if (_logLevelIconSelect) { _logLevelIconSelect.setValue(data.level); } else { const select = document.getElementById('settings-log-level'); if (select) select.value = data.level; } } catch (err) { console.error('Failed to load log level:', err); } } export async function setLogLevel() { const select = document.getElementById('settings-log-level'); if (!select) return; const level = select.value; try { const resp = await fetchWithAuth('/system/log-level', { method: 'PUT', body: JSON.stringify({ level }), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } showToast(t('settings.log_level.saved'), 'success'); } catch (err) { console.error('Failed to set log level:', err); showToast(t('settings.log_level.save_error') + ': ' + err.message, 'error'); } } // ─── MQTT settings ──────────────────────────────────────────── export async function loadMqttSettings() { try { const resp = await fetchWithAuth('/system/mqtt/settings'); if (!resp.ok) return; const data = await resp.json(); document.getElementById('mqtt-enabled').checked = data.enabled; document.getElementById('mqtt-host').value = data.broker_host; document.getElementById('mqtt-port').value = data.broker_port; document.getElementById('mqtt-username').value = data.username; document.getElementById('mqtt-password').value = ''; document.getElementById('mqtt-client-id').value = data.client_id; document.getElementById('mqtt-base-topic').value = data.base_topic; const hint = document.getElementById('mqtt-password-hint'); if (hint) hint.style.display = data.password_set ? '' : 'none'; } catch (err) { console.error('Failed to load MQTT settings:', err); } } export async function saveMqttSettings() { const enabled = document.getElementById('mqtt-enabled').checked; const broker_host = document.getElementById('mqtt-host').value.trim(); const broker_port = parseInt(document.getElementById('mqtt-port').value, 10); const username = document.getElementById('mqtt-username').value; const password = document.getElementById('mqtt-password').value; const client_id = document.getElementById('mqtt-client-id').value.trim(); const base_topic = document.getElementById('mqtt-base-topic').value.trim(); if (!broker_host) { showToast(t('settings.mqtt.error_host_required'), 'error'); return; } try { const resp = await fetchWithAuth('/system/mqtt/settings', { method: 'PUT', body: JSON.stringify({ enabled, broker_host, broker_port, username, password, client_id, base_topic }), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } showToast(t('settings.mqtt.saved'), 'success'); loadMqttSettings(); } catch (err) { console.error('Failed to save MQTT settings:', err); showToast(t('settings.mqtt.save_error') + ': ' + err.message, 'error'); } }