Migrate frontend from JavaScript to TypeScript
- Rename all 54 .js files to .ts, update esbuild entry point - Add tsconfig.json, TypeScript devDependency, typecheck script - Create types.ts with 25+ interfaces matching backend Pydantic schemas (Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource, AudioSource, PictureSource, ScenePreset, SyncClock, Automation, etc.) - Make DataCache generic (DataCache<T>) with typed state instances - Type all state variables in state.ts with proper entity types - Type all create*Card functions with proper entity interfaces - Type all function parameters and return types across all 54 files - Type core component constructors (CardSection, IconSelect, EntitySelect, FilterList, TagInput, TreeNav, Modal) with exported option interfaces - Add comprehensive global.d.ts for window function declarations - Type fetchWithAuth with FetchAuthOpts interface - Remove all (window as any) casts in favor of global.d.ts declarations - Zero tsc errors, esbuild bundle unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
785
server/src/wled_controller/static/js/features/settings.ts
Normal file
785
server/src/wled_controller/static/js/features/settings.ts
Normal file
@@ -0,0 +1,785 @@
|
||||
/**
|
||||
* Settings — tabbed modal (General / Backup / MQTT) + full-screen Log overlay.
|
||||
*/
|
||||
|
||||
import { apiKey } from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth } from '../core/api.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
|
||||
// ─── External URL (used by other modules for user-visible URLs) ──
|
||||
|
||||
let _externalUrl = '';
|
||||
|
||||
/** Get the configured external base URL (empty string = not set). */
|
||||
export function getExternalUrl(): string {
|
||||
return _externalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the base origin for user-visible URLs (webhook, WS).
|
||||
* If an external URL is configured, use that; otherwise fall back to window.location.origin.
|
||||
*/
|
||||
export function getBaseOrigin(): string {
|
||||
return _externalUrl || window.location.origin;
|
||||
}
|
||||
|
||||
export async function loadExternalUrl(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/external-url');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
_externalUrl = data.external_url || '';
|
||||
const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
|
||||
if (input) input.value = _externalUrl;
|
||||
} catch (err) {
|
||||
console.error('Failed to load external URL:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveExternalUrl(): Promise<void> {
|
||||
const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
const url = input.value.trim().replace(/\/+$/, '');
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/external-url', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ external_url: url }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
_externalUrl = data.external_url || '';
|
||||
input.value = _externalUrl;
|
||||
showToast(t('settings.external_url.saved'), 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to save external URL:', err);
|
||||
showToast(t('settings.external_url.save_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Settings-modal tab switching ───────────────────────────
|
||||
|
||||
export function switchSettingsTab(tabId: string): void {
|
||||
document.querySelectorAll('.settings-tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', (btn as HTMLElement).dataset.settingsTab === tabId);
|
||||
});
|
||||
document.querySelectorAll('.settings-panel').forEach(panel => {
|
||||
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Log Viewer ────────────────────────────────────────────
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
let _logWs: WebSocket | null = null;
|
||||
|
||||
/** Level ordering for filter comparisons */
|
||||
const _LOG_LEVELS = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 };
|
||||
|
||||
function _detectLevel(line: string): string {
|
||||
for (const lvl of ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']) {
|
||||
if (line.includes(lvl)) return lvl;
|
||||
}
|
||||
return 'DEBUG';
|
||||
}
|
||||
|
||||
function _levelClass(level: string): string {
|
||||
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(): string {
|
||||
const sel = document.getElementById('log-viewer-filter') as HTMLSelectElement | null;
|
||||
return sel ? sel.value : 'all';
|
||||
}
|
||||
|
||||
function _linePassesFilter(line: string): boolean {
|
||||
const filter = _filterLevel();
|
||||
if (filter === 'all') return true;
|
||||
const lineLvl = _detectLevel(line);
|
||||
return (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
|
||||
}
|
||||
|
||||
function _appendLine(line: string): void {
|
||||
// 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(): void {
|
||||
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(): void {
|
||||
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(): void {
|
||||
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(): void {
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (!output) return;
|
||||
const filter = _filterLevel();
|
||||
for (const span of Array.from(output.children)) {
|
||||
const line = span.textContent;
|
||||
const lineLvl = _detectLevel(line);
|
||||
const passes = filter === 'all' || (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
|
||||
(span as HTMLElement).style.display = passes ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Log Overlay (full-screen) ──────────────────────────────
|
||||
|
||||
let _logFilterIconSelect: IconSelect | null = null;
|
||||
|
||||
/** Build filter items lazily so t() has locale data loaded. */
|
||||
function _getLogFilterItems(): { value: string; icon: string; label: string; desc: string }[] {
|
||||
return [
|
||||
{ value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') },
|
||||
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') },
|
||||
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') },
|
||||
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') },
|
||||
];
|
||||
}
|
||||
|
||||
export function openLogOverlay(): void {
|
||||
const overlay = document.getElementById('log-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
// Initialize log filter icon select (once)
|
||||
if (!_logFilterIconSelect) {
|
||||
const filterSel = document.getElementById('log-viewer-filter') as HTMLSelectElement | null;
|
||||
if (filterSel) {
|
||||
_logFilterIconSelect = new IconSelect({
|
||||
target: filterSel,
|
||||
items: _getLogFilterItems(),
|
||||
columns: 2,
|
||||
onChange: () => applyLogFilter(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-connect when opening
|
||||
if (!_logWs || _logWs.readyState !== WebSocket.OPEN) {
|
||||
connectLogViewer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeLogOverlay(): void {
|
||||
const overlay = document.getElementById('log-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
disconnectLogViewer();
|
||||
}
|
||||
|
||||
// ─── Settings Modal ─────────────────────────────────────────
|
||||
|
||||
// Simple modal (no form / no dirty check needed)
|
||||
const settingsModal = new Modal('settings-modal');
|
||||
|
||||
let _logLevelIconSelect: IconSelect | null = null;
|
||||
|
||||
/** Build log-level items lazily so t() has locale data loaded. */
|
||||
function _getLogLevelItems(): { value: string; icon: string; label: string; desc: string }[] {
|
||||
return [
|
||||
{ value: 'DEBUG', icon: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') },
|
||||
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') },
|
||||
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: 'WARNING', desc: t('settings.log_level.desc.warning') },
|
||||
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: 'ERROR', desc: t('settings.log_level.desc.error') },
|
||||
{ value: 'CRITICAL', icon: '<span style="color:#ff1744;font-weight:700">!</span>', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') },
|
||||
];
|
||||
}
|
||||
|
||||
export function openSettingsModal(): void {
|
||||
(document.getElementById('settings-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
// Reset to first tab
|
||||
switchSettingsTab('general');
|
||||
|
||||
settingsModal.open();
|
||||
|
||||
// Initialize log level icon select
|
||||
if (!_logLevelIconSelect) {
|
||||
const levelSel = document.getElementById('settings-log-level') as HTMLSelectElement | null;
|
||||
if (levelSel) {
|
||||
_logLevelIconSelect = new IconSelect({
|
||||
target: levelSel,
|
||||
items: _getLogLevelItems(),
|
||||
columns: 3,
|
||||
onChange: () => setLogLevel(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadApiKeysList();
|
||||
loadExternalUrl();
|
||||
loadAutoBackupSettings();
|
||||
loadBackupList();
|
||||
loadMqttSettings();
|
||||
loadLogLevel();
|
||||
}
|
||||
|
||||
export function closeSettingsModal(): void {
|
||||
settingsModal.forceClose();
|
||||
}
|
||||
|
||||
// ─── Backup ────────────────────────────────────────────────
|
||||
|
||||
export async function downloadBackup(): Promise<void> {
|
||||
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: HTMLInputElement): Promise<void> {
|
||||
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(): Promise<void> {
|
||||
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?: string): void {
|
||||
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 =
|
||||
'<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">${msg}</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(): void {
|
||||
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(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/auto-backup/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
(document.getElementById('auto-backup-enabled') as HTMLInputElement).checked = data.enabled;
|
||||
(document.getElementById('auto-backup-interval') as HTMLInputElement).value = String(data.interval_hours);
|
||||
(document.getElementById('auto-backup-max') as HTMLInputElement).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(): Promise<void> {
|
||||
const enabled = (document.getElementById('auto-backup-enabled') as HTMLInputElement).checked;
|
||||
const interval_hours = parseFloat((document.getElementById('auto-backup-interval') as HTMLInputElement).value);
|
||||
const max_backups = parseInt((document.getElementById('auto-backup-max') as HTMLInputElement).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(): Promise<void> {
|
||||
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 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
|
||||
? `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--border-color);color:var(--text-muted);white-space:nowrap;">${t('settings.saved_backups.type.auto')}</span>`
|
||||
: `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--primary-color);color:#fff;white-space:nowrap;">${t('settings.saved_backups.type.manual')}</span>`;
|
||||
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;">
|
||||
${typeBadge}
|
||||
<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;">${sizeStr}</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;">${ICON_UNDO}</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;">${ICON_DOWNLOAD}</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: string): Promise<void> {
|
||||
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: string): Promise<void> {
|
||||
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: string): Promise<void> {
|
||||
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(): Promise<void> {
|
||||
const container = document.getElementById('settings-api-keys-list');
|
||||
if (!container) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/api-keys');
|
||||
if (!resp.ok) {
|
||||
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.load_error')}</div>`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.count === 0) {
|
||||
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = data.keys.map(k =>
|
||||
`<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid var(--border-color);">
|
||||
<span style="font-weight:600;min-width:80px;">${k.label}</span>
|
||||
<code style="flex:1;color:var(--text-muted);font-size:0.8rem;">${k.masked}</code>
|
||||
</div>`
|
||||
).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load API keys:', err);
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Partial Export / Import ───────────────────────────────────
|
||||
|
||||
export async function downloadPartialExport(): Promise<void> {
|
||||
const storeKey = (document.getElementById('settings-partial-store') as HTMLSelectElement).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: HTMLInputElement): Promise<void> {
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
|
||||
const storeKey = (document.getElementById('settings-partial-store') as HTMLSelectElement).value;
|
||||
const merge = (document.getElementById('settings-partial-merge') as HTMLInputElement).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(): Promise<void> {
|
||||
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') as HTMLSelectElement | null;
|
||||
if (select) select.value = data.level;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load log level:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLogLevel(): Promise<void> {
|
||||
const select = document.getElementById('settings-log-level') as HTMLSelectElement | null;
|
||||
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(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/mqtt/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
(document.getElementById('mqtt-enabled') as HTMLInputElement).checked = data.enabled;
|
||||
(document.getElementById('mqtt-host') as HTMLInputElement).value = data.broker_host;
|
||||
(document.getElementById('mqtt-port') as HTMLInputElement).value = data.broker_port;
|
||||
(document.getElementById('mqtt-username') as HTMLInputElement).value = data.username;
|
||||
(document.getElementById('mqtt-password') as HTMLInputElement).value = '';
|
||||
(document.getElementById('mqtt-client-id') as HTMLInputElement).value = data.client_id;
|
||||
(document.getElementById('mqtt-base-topic') as HTMLInputElement).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(): Promise<void> {
|
||||
const enabled = (document.getElementById('mqtt-enabled') as HTMLInputElement).checked;
|
||||
const broker_host = (document.getElementById('mqtt-host') as HTMLInputElement).value.trim();
|
||||
const broker_port = parseInt((document.getElementById('mqtt-port') as HTMLInputElement).value, 10);
|
||||
const username = (document.getElementById('mqtt-username') as HTMLInputElement).value;
|
||||
const password = (document.getElementById('mqtt-password') as HTMLInputElement).value;
|
||||
const client_id = (document.getElementById('mqtt-client-id') as HTMLInputElement).value.trim();
|
||||
const base_topic = (document.getElementById('mqtt-base-topic') as HTMLInputElement).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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user