mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
* Add settings tooltips and search to the web UI Help users quickly find settings and understand how each one works. Tooltips: a new delegated controller (static/v3/js/tooltips.js) drives an accessible (i) info tooltip that appears on hover, keyboard focus, and tap. A shared `help_tip` Jinja macro (partials/_macros.html) emits the trigger; the plugin config macro and the core settings partials now surface help text through it. Per the design, the always-visible field help paragraphs are folded into the tooltip to declutter the forms, and the hardware/display settings carry authored detail (default, range, recommendation). Search: a global header search box finds settings across every settings tab — even ones not yet opened — via a lazy client-side index built by scanning the same field markup (static/v3/js/settings-search.js). Selecting a result switches tabs, waits for the field to load, then scrolls to and flashes it. A per-tab filter box hides non-matching fields on the current tab. Plugin settings get tooltips for free by reusing each field's schema `description`; every settings field also gets a stable `setting-<tab>-<key>` anchor id for search navigation. Styling uses the existing --color-* theme vars so light/dark mode both work, and honors prefers-reduced-motion. Adds Flask render smoke tests that assert each settings partial ships tooltips, anchors, and a filter box. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Address Codacy static-analysis findings in settings JS Refactor the two new modules to clear the flagged patterns without any behavior change: - Build the search dropdown with DOM nodes + textContent instead of innerHTML string concatenation, removing the XSS sinks and the manual escapeHtml helper it needed. - Replace numeric index access (index[i], terms[j], opts[idx], currentResults[i]) with array iteration methods, NodeList.item(), and Array.prototype.at() to clear detect-object-injection. - Use === via a shared termsMatch() helper, optional-catch binding, and drop a useless initial assignment. Verified with ESLint (eslint:recommended + eslint-plugin-security) at zero findings and re-ran the headless-Chromium behavior test (tooltip, per-tab filter, global search navigation) — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Reduce complexity of revealAncestors in settings search Extract isNodeHidden() and revealNode() helpers so revealAncestors drops below the cyclomatic-complexity threshold. No behavior change; verified with the headless-Chromium test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Resolve remaining Codacy findings in settings search - Validate plugin ids against a strict allowlist (mirroring the server's _SAFE_PLUGIN_ID_RE) before they can appear in a fetch path, so the request URL is never built from unvalidated input (Codacy: user-controlled URL). - Document that the fetched HTML is parsed into an inert document (scripts never run, never inserted into the live DOM) purely to read field text for the search index. - Declare block-scoped locals with const instead of var where they were nested inside conditionals (Codacy: var not at function root). No behavior change; re-verified with ESLint and the headless-Chromium test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Serve the settings search index from the server as JSON Move index building off the client so there is no client-side HTML fetching or DOM parsing (resolves Codacy's variable-fetch and DOMParser flags on the read-only, same-origin index build). - Add GET /v3/settings/search-index (pages_v3.py): renders the settings partials server-side and extracts each field's anchor id, key, label, tooltip, and section with a small stdlib HTMLParser, then caches the result keyed on the installed-plugin set. Parsing the rendered HTML keeps anchor ids identical to the live DOM, so the index cannot drift. - settings-search.js: buildIndex() now does a single fetch of the literal endpoint + .json(); removed the per-partial fetch loop, DOMParser, scanDoc, CORE_TABS, and the plugin-id allowlist. Search, keyboard nav, navigation, and the per-tab filter are unchanged. Net: fewer requests and no client-side HTML parsing. Verified with a new endpoint test in test_web_settings_ui.py (12 pass) and the headless-Chromium test (tooltip, filter, search navigate + flash all green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Address CodeRabbit review on settings search/tooltips - pages_v3: include Durations tab in the search index so setting-durations-* fields are actually indexed - test_web_settings_ui: assert setting-durations-clock is present in the search-index endpoint response - settings-search.js: on index fetch failure, reset buildPromise instead of caching an empty (truthy) index so search can retry - settings-search.js: filterScope returns null (not document) when no tab container matches, and the caller guards, so the per-tab filter can't hide fields across unrelated tabs - settings-search.js: refresh the stale header comment to describe the server-side JSON index flow - app.css: cap #settings-search-results height with overflow-y so the dropdown scrolls instead of overflowing small screens Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Fix stuck search dropdown; add plugin-tab nested-settings filter Global search: - Close the results dropdown on input blur (guarded, with a short delay) so it reliably dismisses when focus leaves — previously it could linger because the only outside-close was a document click that Alpine/HTMX handlers can swallow. - Clear the query text after navigating to a result so refocusing the box doesn't re-open stale results. - Also dismiss on htmx:afterSwap (tab changes / navigation). Per-tab filter (now on plugin tabs too): - Render the shared settings_filter box in the plugin Configuration panel. It auto-wires: the delegated input handler and filterScope already target .plugin-config-tab. - Teach applyTabFilter to reveal matches inside collapsed nested sections (render_nested_section defaults them shut), hide nested-section wrappers with no matches, and restore the original collapsed layout when cleared (only re-collapsing sections the filter itself opened). - Count a visible nested-section as content for its parent heading so the heading isn't hidden while a subsection below still has matches. Adds a plugin-config render test (filter box + nested anchors + tooltips). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Close search dropdown via capture-phase outside-click The dropdown could stay open after clicking away because the only outside-click close was a bubble-phase document listener. The v3 UI is one Alpine app() component full of HTMX/Alpine/widget click handlers; when a click lands inside an element that calls stopPropagation(), the event never bubbles to document and the close never runs. - Replace the bubble-phase document 'click' close with a capture-phase 'pointerdown' listener scoped to #settings-search-wrap. Capture runs before any bubbling stopPropagation can swallow the event, so it always fires; pointerdown also covers touch on the Pi screen. Clicking a result stays inside the wrap, so selection is unaffected. - Guard the debounced input handler so a delayed render can't re-open the box after focus has left (type-then-click-away race). Keeps the existing blur / Escape / htmx:afterSwap closes as secondary paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Fix settings search dropdown not visually closing .hidden has no effect in this app: app.css is a hand-picked utility subset (no Tailwind build step) and never defines .hidden { display: none }. openResults()/closeResults() only toggled the class, so the dropdown stayed rendered (display: block) even once closeResults() ran - confirmed via computed style in a headless browser. Set style.display directly, matching the fallback already used by revealNode()/collapseNode() elsewhere in this file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
360 lines
19 KiB
HTML
360 lines
19 KiB
HTML
{% import 'v3/partials/_macros.html' as ui %}
|
|
<div class="space-y-6" id="backup-restore-root">
|
|
|
|
<!-- Security warning -->
|
|
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div class="flex">
|
|
<div class="flex-shrink-0">
|
|
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
|
</div>
|
|
<div class="ml-3">
|
|
<h3 class="text-sm font-medium text-red-800">Backup files contain secrets in plaintext</h3>
|
|
<div class="mt-1 text-sm text-red-700">
|
|
Your API keys (weather, Spotify, YouTube, GitHub, etc.) and any saved WiFi passwords
|
|
are stored inside the backup ZIP as plain text. Treat the file like a password —
|
|
store it somewhere private and delete it when you no longer need it.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Export card -->
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900">Export backup</h2>
|
|
<p class="mt-1 text-sm text-gray-600">
|
|
Download a single ZIP with all of your settings so you can restore it later.
|
|
</p>
|
|
</div>
|
|
<button onclick="exportBackup()" id="export-backup-btn"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
|
<i class="fas fa-download mr-2"></i>
|
|
Download backup
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="export-preview" class="text-sm text-gray-600">
|
|
<div class="animate-pulse">Loading summary…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Restore card -->
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-900">Restore from backup</h2>
|
|
<p class="mt-1 text-sm text-gray-600">
|
|
Upload a backup ZIP exported from this or another LEDMatrix install.
|
|
You'll see a summary before anything is written to disk.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
|
|
<input type="file" id="restore-file-input" accept=".zip"
|
|
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
|
</div>
|
|
|
|
<div>
|
|
<button onclick="validateRestoreFile()" id="validate-restore-btn"
|
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
|
<i class="fas fa-check-circle mr-2"></i>
|
|
Inspect file
|
|
</button>
|
|
</div>
|
|
|
|
<div id="restore-preview" class="hidden bg-gray-50 border border-gray-200 rounded-md p-4">
|
|
<h3 class="text-sm font-medium text-gray-900 mb-2">Backup contents</h3>
|
|
<dl id="restore-preview-body" class="text-sm text-gray-700 space-y-1"></dl>
|
|
|
|
<h3 class="text-sm font-medium text-gray-900 mt-4 mb-2">Choose what to restore</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-700">
|
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-config" checked> <span>Main configuration</span>{{ ui.help_tip('Restore config.json — display settings, schedules, location, and plugin configuration.', 'Main configuration') }}</label>
|
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-secrets" checked> <span>API keys (secrets)</span>{{ ui.help_tip('Restore config_secrets.json — your weather, sports, and other service API keys.\nLeave off if you prefer to re-enter keys by hand.', 'API keys') }}</label>
|
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-wifi" checked> <span>WiFi configuration</span>{{ ui.help_tip('Restore saved WiFi network names and credentials.', 'WiFi configuration') }}</label>
|
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-fonts" checked> <span>User-uploaded fonts</span>{{ ui.help_tip('Restore any custom fonts you uploaded via the Fonts tab.', 'User-uploaded fonts') }}</label>
|
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-plugin-uploads" checked> <span>Plugin image uploads</span>{{ ui.help_tip('Restore images and files that plugins let you upload (logos, backgrounds, etc.).', 'Plugin image uploads') }}</label>
|
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-reinstall" checked> <span>Reinstall missing plugins</span>{{ ui.help_tip('After restoring, re-download any plugins that were installed in the backup but are missing now.', 'Reinstall missing plugins') }}</label>
|
|
</div>
|
|
|
|
<div class="mt-4 flex gap-2">
|
|
<button onclick="runRestore()" id="run-restore-btn"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
|
<i class="fas fa-upload mr-2"></i>
|
|
Restore now
|
|
</button>
|
|
<button onclick="clearRestore()"
|
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="restore-result" class="hidden"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- History card -->
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900">Backup history</h2>
|
|
<p class="mt-1 text-sm text-gray-600">Previously exported backups stored on this device.</p>
|
|
</div>
|
|
<button onclick="loadBackupList()"
|
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
|
<i class="fas fa-sync-alt mr-2"></i>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="backup-history" class="text-sm text-gray-600">Loading…</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
let inspectedFile = null;
|
|
|
|
function notify(message, kind) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(message, kind || 'info');
|
|
} else {
|
|
console.log('[backup]', kind || 'info', message);
|
|
}
|
|
}
|
|
|
|
function formatSize(bytes) {
|
|
if (!bytes) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
let i = 0, size = bytes;
|
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
|
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
|
|
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
|
});
|
|
}
|
|
|
|
async function loadPreview() {
|
|
const el = document.getElementById('export-preview');
|
|
try {
|
|
const res = await fetch('/api/v3/backup/preview');
|
|
const payload = await res.json();
|
|
if (payload.status !== 'success') throw new Error(payload.message || 'Preview failed');
|
|
const d = payload.data || {};
|
|
el.innerHTML = `
|
|
<ul class="list-disc pl-5 space-y-1">
|
|
<li>Main config: <strong>${d.has_config ? 'yes' : 'no'}</strong></li>
|
|
<li>Secrets: <strong>${d.has_secrets ? 'yes' : 'no'}</strong></li>
|
|
<li>WiFi config: <strong>${d.has_wifi ? 'yes' : 'no'}</strong></li>
|
|
<li>User fonts: <strong>${(d.user_fonts || []).length}</strong> ${d.user_fonts && d.user_fonts.length ? '(' + d.user_fonts.map(escapeHtml).join(', ') + ')' : ''}</li>
|
|
<li>Plugin image uploads: <strong>${d.plugin_uploads || 0}</strong> file(s)</li>
|
|
<li>Installed plugins: <strong>${(d.plugins || []).length}</strong></li>
|
|
</ul>`;
|
|
} catch (err) {
|
|
el.textContent = 'Could not load preview: ' + err.message;
|
|
}
|
|
}
|
|
|
|
async function loadBackupList() {
|
|
const el = document.getElementById('backup-history');
|
|
el.textContent = 'Loading…';
|
|
try {
|
|
const res = await fetch('/api/v3/backup/list');
|
|
const payload = await res.json();
|
|
if (payload.status !== 'success') throw new Error(payload.message || 'List failed');
|
|
const entries = payload.data || [];
|
|
if (!entries.length) {
|
|
el.innerHTML = '<p>No backups have been created yet.</p>';
|
|
return;
|
|
}
|
|
el.innerHTML = `
|
|
<table class="min-w-full divide-y divide-gray-200">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left py-2">Filename</th>
|
|
<th class="text-left py-2">Size</th>
|
|
<th class="text-left py-2">Created</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100">
|
|
${entries.map(e => `
|
|
<tr>
|
|
<td class="py-2 font-mono text-xs">${escapeHtml(e.filename)}</td>
|
|
<td class="py-2">${formatSize(e.size)}</td>
|
|
<td class="py-2">${escapeHtml(e.created_at)}</td>
|
|
<td class="py-2 text-right space-x-2">
|
|
<a href="/api/v3/backup/download/${encodeURIComponent(e.filename)}"
|
|
class="text-blue-600 hover:underline">Download</a>
|
|
<button data-filename="${escapeHtml(e.filename)}"
|
|
class="text-red-600 hover:underline backup-delete-btn">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>`;
|
|
el.querySelectorAll('.backup-delete-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => deleteBackup(btn.dataset.filename));
|
|
});
|
|
} catch (err) {
|
|
el.textContent = 'Could not load backups: ' + err.message;
|
|
}
|
|
}
|
|
|
|
async function exportBackup() {
|
|
const btn = document.getElementById('export-backup-btn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Creating…';
|
|
try {
|
|
const res = await fetch('/api/v3/backup/export', { method: 'POST' });
|
|
const payload = await res.json();
|
|
if (payload.status !== 'success') throw new Error(payload.message || 'Export failed');
|
|
notify('Backup created: ' + payload.filename, 'success');
|
|
// Trigger browser download immediately.
|
|
window.location.href = '/api/v3/backup/download/' + encodeURIComponent(payload.filename);
|
|
await loadBackupList();
|
|
} catch (err) {
|
|
notify('Export failed: ' + err.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-download mr-2"></i>Download backup';
|
|
}
|
|
}
|
|
|
|
async function deleteBackup(filename) {
|
|
if (!confirm('Delete ' + filename + '?')) return;
|
|
try {
|
|
const res = await fetch('/api/v3/backup/' + encodeURIComponent(filename), { method: 'DELETE' });
|
|
const payload = await res.json();
|
|
if (payload.status !== 'success') throw new Error(payload.message || 'Delete failed');
|
|
notify('Backup deleted', 'success');
|
|
await loadBackupList();
|
|
} catch (err) {
|
|
notify('Delete failed: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function validateRestoreFile() {
|
|
const input = document.getElementById('restore-file-input');
|
|
if (!input.files || !input.files[0]) {
|
|
notify('Choose a backup file first', 'error');
|
|
return;
|
|
}
|
|
const file = input.files[0];
|
|
const fd = new FormData();
|
|
fd.append('backup_file', file);
|
|
try {
|
|
const res = await fetch('/api/v3/backup/validate', { method: 'POST', body: fd });
|
|
const payload = await res.json();
|
|
if (payload.status !== 'success') throw new Error(payload.message || 'Validation failed');
|
|
inspectedFile = file;
|
|
renderRestorePreview(payload.data);
|
|
} catch (err) {
|
|
notify('Invalid backup: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderRestorePreview(manifest) {
|
|
const wrap = document.getElementById('restore-preview');
|
|
const body = document.getElementById('restore-preview-body');
|
|
const detected = manifest.detected_contents || [];
|
|
const plugins = manifest.plugins || [];
|
|
body.innerHTML = `
|
|
<div><strong>Created:</strong> ${escapeHtml(manifest.created_at || 'unknown')}</div>
|
|
<div><strong>Source host:</strong> ${escapeHtml(manifest.hostname || 'unknown')}</div>
|
|
<div><strong>LEDMatrix version:</strong> ${escapeHtml(manifest.ledmatrix_version || 'unknown')}</div>
|
|
<div><strong>Includes:</strong> ${detected.length ? detected.map(escapeHtml).join(', ') : '(nothing detected)'}</div>
|
|
<div><strong>Plugins referenced:</strong> ${plugins.length ? plugins.map(p => escapeHtml(p.plugin_id)).join(', ') : 'none'}</div>
|
|
`;
|
|
wrap.classList.remove('hidden');
|
|
}
|
|
|
|
function clearRestore() {
|
|
inspectedFile = null;
|
|
document.getElementById('restore-preview').classList.add('hidden');
|
|
document.getElementById('restore-result').classList.add('hidden');
|
|
document.getElementById('restore-file-input').value = '';
|
|
}
|
|
|
|
async function runRestore() {
|
|
if (!inspectedFile) {
|
|
notify('Inspect the file before restoring', 'error');
|
|
return;
|
|
}
|
|
if (!confirm('Restore from this backup? Current configuration will be overwritten.')) return;
|
|
|
|
const options = {
|
|
restore_config: document.getElementById('opt-config').checked,
|
|
restore_secrets: document.getElementById('opt-secrets').checked,
|
|
restore_wifi: document.getElementById('opt-wifi').checked,
|
|
restore_fonts: document.getElementById('opt-fonts').checked,
|
|
restore_plugin_uploads: document.getElementById('opt-plugin-uploads').checked,
|
|
reinstall_plugins: document.getElementById('opt-reinstall').checked,
|
|
};
|
|
const fd = new FormData();
|
|
fd.append('backup_file', inspectedFile);
|
|
fd.append('options', JSON.stringify(options));
|
|
|
|
const btn = document.getElementById('run-restore-btn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Restoring…';
|
|
try {
|
|
const res = await fetch('/api/v3/backup/restore', { method: 'POST', body: fd });
|
|
const payload = await res.json();
|
|
if (payload.status !== 'success') {
|
|
const msgs = (payload.data?.errors || []).join('; ');
|
|
throw new Error(payload.message || msgs || 'Restore had errors');
|
|
}
|
|
const data = payload.data || {};
|
|
const hasPartial = (data.plugins_failed || []).length > 0 || (data.errors || []).length > 0;
|
|
const result = document.getElementById('restore-result');
|
|
result.className = (hasPartial
|
|
? 'bg-yellow-50 border-yellow-200 text-yellow-800'
|
|
: 'bg-green-50 border-green-200 text-green-800') + ' border rounded-md p-4';
|
|
result.classList.remove('hidden');
|
|
result.innerHTML = `
|
|
<h3 class="font-medium mb-2">${hasPartial ? 'Restore complete with warnings' : 'Restore complete'}</h3>
|
|
<div><strong>Restored:</strong> ${(data.restored || []).map(escapeHtml).join(', ') || 'none'}</div>
|
|
<div><strong>Skipped:</strong> ${(data.skipped || []).map(escapeHtml).join(', ') || 'none'}</div>
|
|
<div><strong>Plugins installed:</strong> ${(data.plugins_installed || []).map(escapeHtml).join(', ') || 'none'}</div>
|
|
<div><strong>Plugins failed:</strong> ${(data.plugins_failed || []).map(p => escapeHtml(p.plugin_id + ' (' + p.error + ')')).join(', ') || 'none'}</div>
|
|
<div><strong>Errors:</strong> ${(data.errors || []).map(escapeHtml).join('; ') || 'none'}</div>
|
|
${((data.restored || []).length || (data.plugins_installed || []).length) ? '<p class="mt-2">Restart the display service to apply all changes.</p>' : ''}
|
|
`;
|
|
notify(hasPartial ? 'Restore complete with warnings' : 'Restore complete', hasPartial ? 'warning' : 'success');
|
|
} catch (err) {
|
|
notify('Restore failed: ' + err.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-upload mr-2"></i>Restore now';
|
|
}
|
|
}
|
|
|
|
// Expose handlers to inline onclick attributes.
|
|
window.exportBackup = exportBackup;
|
|
window.loadBackupList = loadBackupList;
|
|
window.validateRestoreFile = validateRestoreFile;
|
|
window.clearRestore = clearRestore;
|
|
window.runRestore = runRestore;
|
|
|
|
// Clear inspection state whenever the user picks a new file.
|
|
document.getElementById('restore-file-input').addEventListener('change', function () {
|
|
inspectedFile = null;
|
|
document.getElementById('restore-preview').classList.add('hidden');
|
|
document.getElementById('restore-result').classList.add('hidden');
|
|
});
|
|
|
|
// Initial load.
|
|
loadPreview();
|
|
loadBackupList();
|
|
})();
|
|
</script>
|