feat(web): Rotation, Operation History, Config Editor and Backup & Restore become ES-module pages (stage 2) (#727)

Rotation, Operation History, Config Editor and Backup & Restore become ES-module pages (stage 2): no inline scripts or onclick in the four partials, delegated data-action listeners, reads cancelled on swap-out, old globals kept as deprecated aliases through window.LEDMatrix, and four new DOM suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-02 18:02:03 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c14002edc3
commit a7b3f33952
22 changed files with 2019 additions and 819 deletions
+23
View File
@@ -19,6 +19,29 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
### Web UI: four more tabs are ES-module pages (stage 2)
- Rotation, Operation History, Config Editor and Backup & Restore follow the
Cache tab (#703): each partial's inline `<script>` is now
`static/v3/js/pages/<name>.js` (`durations`, `operation-history`,
`raw-json`, `backup-restore`), started once per swap-in by the page
registry and stopped on swap-out. None of the four partials has an inline
script or `onclick` any more. Buttons carry `data-action` and use one
delegated listener. Server data is drawn with `textContent`.
- Old globals keep working as deprecated aliases through `window.LEDMatrix`
(one console warning each): `formatJson`, `manualValidateJson`,
`validateJSON`, `saveMainConfig`, `saveSecretsConfig`, `exportBackup`,
`loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`.
- Reads are cancelled when a tab is swapped away. Writes (save, delete,
export, restore) are not, and their result is still reported.
- `core/api.js` accepts a raw `body` (a `FormData` upload).
`PluginOrderList.init()` accepts a `signal` for its plugin-list request.
- Small fixes on the way: the Config Editor's "Invalid JSON" line no longer
puts the parser's message into `innerHTML`, and Operation History's
"Showing x to y of z" now resets when nothing matches.
- New DOM suites `test/js/dom/test_{durations,operation_history,raw_json,backup_restore}_page.js`.
`test/web_interface/test_es_modules.py` pins the converted pages and the aliases.
### Garbage-collection pauses in the frame stats
- The display now times every Python garbage collection
+33 -8
View File
@@ -46,6 +46,7 @@ static/v3/js/
store.js (the one installed-plugin store), form/renderer.js
pages/ one module per tab partial
cache.js export init(root, ctx), destroy(root, ctx)
durations.js, operation-history.js, raw-json.js, backup-restore.js
...
```
@@ -57,9 +58,29 @@ A converted partial has no `<script>`. Its root element names its page:
<div class="..." data-page="cache"> ... </div>
```
`core/boot.js` registers each page with a loader:
`registry.register('cache', () => import('../pages/cache.js'))`. A page's
module is fetched only when its partial first appears.
`core/boot.js` lists each page with a loader,
`'cache': page(function() { return import('../pages/cache.js'); })`, and
registers them all. A page's module is fetched only when its partial first
appears. `page()` remembers the module once loaded, so the alias of an old
synchronous global (`validateJSON` returns a boolean) still answers
synchronously while its page is on screen.
The conventions the converted pages share:
- **Buttons name an action.** A partial's buttons carry `data-action` (and
any argument as another `data-*` attribute) instead of an `onclick` that
names a global. One delegated listener on the page root handles them all,
including rows drawn later.
- **Server data is drawn with `textContent`**, never a markup string.
- **Reads are cancelled, writes are not.** Loads pass `ctx.signal`, so a swap
cancels them. Saves, deletes, exports and restores do not: the server
finishes them anyway, so the page still reports the result in a
notification but draws nothing into a page that has gone.
- **Old globals become aliases.** Each `window.*` name a page used to define
is made in `boot.js` with `alias(page, name, replacement)`, which forwards
to the module's export of the same name and warns once.
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
undo by itself.
`core/registry.js` handles the rest:
@@ -214,10 +235,10 @@ are the inline script in each partial today.
| # | Page | Inline JS | Why it is here |
|---|---|---|---|
| 1 | Cache (`cache.html`) | 163 lines, now 0 | **Done in stage 1.** One endpoint pair, no globals other pages use. The reference conversion |
| 2 | Rotation (`durations.html`) | 29 | Tiny. One htmx form |
| 3 | Operation History | 293 | No globals, read-only list |
| 4 | Config Editor (`raw_json.html`) | 212 | No globals. CodeMirror is set up and torn down in init/destroy |
| 5 | Backup & Restore | 232 | 5 globals used only by its own `onclick`s; these become delegated listeners plus deprecated aliases |
| 2 | Rotation (`durations.html`) | 29 lines, now 0 | **Done in stage 2.** The form stays plain htmx; the page starts the shared rotation-order widget, whose plugin-list request now takes `ctx.signal`. Its `hx-on` and `onsubmit` attributes call shared globals (`showSaveResult`, `fixInvalidNumberInputs`) and move with step 6 |
| 3 | Operation History | 293 lines, now 0 | **Done in stage 2.** Read-only list; rows drawn with `textContent`, the search debounce cleared on destroy. The "Showing x to y" counters now also reset when nothing matches |
| 4 | Config Editor (`raw_json.html`) | 212 lines, now 0 | **Done in stage 2.** Plain textareas (no CodeMirror on this page). It defined 5 globals after all (`formatJson`, `manualValidateJson`, `validateJSON`, `saveMainConfig`, `saveSecretsConfig`); nothing else used them, and they are deprecated aliases now. The live "Invalid JSON" line no longer puts the parser's message into `innerHTML` |
| 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) |
| 6 | Schedule | 193 | 2 globals used as `hx-on` response handlers. Moves `hx-on` handlers into page listeners |
| 7 | General | 147 | `webLogin` global and the security section. The first page that touches login |
| 8 | Display | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy |
@@ -260,7 +281,11 @@ Unit suites need only node. They import the shipped modules directly:
| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment |
| `unit/test_core_modules.js` | Unit | `api.js` (envelope, errors, abort, login redirect, path check) and `facade.js` (facade, aliases) |
| `dom/test_cache_page.js` | DOM: real partial, real API shape | No inline script; one request per swap and per Refresh after five swaps; a cancelled request draws nothing; hostile keys stay text; delete, empty, error, network and login states |
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; every registered page has its module and exactly one partial root; a converted partial has no `<script>` |
| `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text |
| `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text |
| `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points |
| `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points |
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no `<script>` and no `onclick`; every moved global is aliased in `boot.js` and exported by its module, and no template defines it any more |
| `test/test_field_model_parity.py` | pytest | The model against the macro for every available schema |
What each future step adds:
+4
View File
@@ -58,6 +58,10 @@ server has none.
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
| `dom/test_cache_page.js` | yes | The Cache tab as a page module (`js/pages/cache.js`) on the real partial: no inline script, one request per swap and per Refresh after repeated swaps, a cancelled request draws nothing, hostile keys stay text, delete/empty/error/login states |
| `dom/test_durations_page.js` | yes | The Rotation tab (`js/pages/durations.js`) with the real `plugin-order-list.js` widget: one plugin-list request per swap, one move per click after repeated swaps, a swap cancels the request in flight, a late widget is waited for |
| `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text |
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
+305
View File
@@ -0,0 +1,305 @@
// The Backup & Restore tab as a page module
// (static/v3/js/pages/backup-restore.js), in a real DOM (jsdom) with the real
// server-rendered partial and the real API's payload shapes. Built like
// test_cache_page.js:
//
// * the partial ships no <script> and no onclick; its root is
// data-page="backup-restore" and its buttons name an action
// * after five swaps each button makes exactly one request
// * reads in flight are cancelled by a swap; writes (export, delete,
// restore) are not, and their result is still reported
// * file names, host names, plugin ids and messages are shown as text
// * the five old globals' entry points still work
const http = require('http');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/backup-restore');
const realPreview = JSON.parse(await get('/api/v3/backup/preview'));
const realList = JSON.parse(await get('/api/v3/backup/list'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const backupPage = await load('pages/backup-restore.js');
console.log('\n── Backup & Restore tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline handlers', !/onclick=/i.test(partial));
ok('the partial root is data-page="backup-restore"', /data-page="backup-restore"/.test(partial));
ok('its buttons name an action', ['export', 'inspect', 'restore', 'cancel', 'refresh']
.every(a => partial.includes(`data-action="${a}"`)));
ok('the real preview answers in the shape the page reads', realPreview.status === 'success'
&& ['has_config', 'has_secrets', 'has_wifi', 'user_fonts', 'plugin_uploads', 'plugins'].every(k => k in realPreview.data),
realPreview);
ok('the real list answers in the shape the page reads', realList.status === 'success' && Array.isArray(realList.data), realList);
const HOSTILE = '<img src=x onerror="window.pwned=1">';
const ZIP = 'ledmatrix-backup-host-20260930.zip';
const ODD = 'odd name #1 ' + HOSTILE + '.zip';
const preview = Object.assign({}, realPreview.data, {
has_config: true, has_secrets: false, has_wifi: true, user_fonts: ['a.bdf', HOSTILE], plugin_uploads: 3, plugins: [1, 2],
});
const listing = [
{ filename: ZIP, size: 2048, created_at: '2026-09-30 12:00:00' },
{ filename: ODD, size: 0, created_at: HOSTILE },
];
const manifest = {
created_at: '2026-09-30T12:00:00', hostname: HOSTILE, ledmatrix_version: '3.8.0',
detected_contents: ['config', 'secrets'], plugins: [{ plugin_id: 'clock' }, { plugin_id: HOSTILE }],
};
const errs = [];
let navigations = 0;
const vc = new VirtualConsole();
vc.on('jsdomError', e => {
const msg = String(e.message || e).split('\n')[0];
// jsdom does not navigate; the export's download is that navigation.
if (/Not implemented: navigation/.test(msg)) { navigations++; return; }
errs.push(msg);
});
vc.on('error', (...a) => errs.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="backup-restore-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('backup-restore-content');
let mode = 'ok';
let restoreAnswer = null;
let aborted = 0;
const requests = [];
const pending = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method, body: init.body, signal: init.signal });
const respond = (status, body) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify(body)),
});
if (mode === 'hang') {
return new Promise((resolve, reject) => {
pending.push(() => resolve(respond(200, { status: 'success', data: [] })));
if (init.signal) init.signal.addEventListener('abort', () => {
aborted++;
const e = new Error('aborted'); e.name = 'AbortError'; reject(e);
});
});
}
if (url === '/api/v3/backup/preview') return respond(200, { status: 'success', data: preview });
if (url === '/api/v3/backup/list') return respond(200, { status: 'success', data: listing });
if (url === '/api/v3/backup/export') return respond(200, { status: 'success', filename: ZIP });
if (url === '/api/v3/backup/validate') return respond(200, { status: 'success', data: manifest });
if (url === '/api/v3/backup/restore') return restoreAnswer();
if (init.method === 'DELETE') return respond(200, { status: 'success' });
return respond(404, { status: 'error', message: 'unexpected ' + url });
}
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
});
registry.register('backup-restore', backupPage);
const count = (url, method = 'GET') => requests.filter(r => r.url === url && r.method === method).length;
const lists = () => count('/api/v3/backup/list');
const $ = id => doc.getElementById(id);
const action = a => doc.querySelector(`button[data-action="${a}"]`);
const hidden = id => $(id).classList.contains('hidden');
async function swap() {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = partial;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
function pick(file) {
Object.defineProperty($('restore-file-input'), 'files', { value: file ? [file] : [], configurable: true });
$('restore-file-input').dispatchEvent(new window.Event('change', { bubbles: true }));
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('one preview and one list request on start',
count('/api/v3/backup/preview') === 1 && lists() === 1, requests.map(r => r.url));
ok('the summary is drawn', /Main config: yes/.test($('export-preview').textContent)
&& /Secrets: no/.test($('export-preview').textContent) && /Plugin image uploads: 3 file\(s\)/.test($('export-preview').textContent),
$('export-preview').textContent);
ok('a hostile font name is text', $('export-preview').textContent.includes(HOSTILE));
const rows = () => doc.querySelectorAll('#backup-history tbody tr');
ok('one history row per backup', rows().length === 2, rows().length);
ok('a hostile file name and date are text', rows()[1].textContent.includes(ODD) && rows()[1].textContent.includes(HOSTILE));
ok('...and created no element', !doc.querySelector('#backup-restore-content img') && !window.pwned);
ok('download links encode the name', rows()[1].querySelector('a').getAttribute('href')
=== '/api/v3/backup/download/' + encodeURIComponent(ODD), rows()[1].querySelector('a').getAttribute('href'));
ok('delete buttons carry the exact name and no handler',
rows()[1].querySelector('button[data-action="delete"]').dataset.filename === ODD
&& !rows()[1].querySelector('button').getAttribute('onclick'));
// ── repeated swaps ──────────────────────────────────────────────────────
const oldRefresh = action('refresh');
for (let i = 0; i < 5; i++) await swap();
ok('one list request per swap', lists() === 6, lists());
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
let before = lists();
action('refresh').click();
await tick(20);
ok('Refresh makes exactly one request (no duplicate listeners)', lists() === before + 1, lists() - before);
oldRefresh.click();
await tick(20);
ok('a swapped-out button does nothing', lists() === before + 1, lists() - before);
// ── delete ──────────────────────────────────────────────────────────────
let asked = null;
window.confirm = msg => { asked = msg; return false; };
rows()[1].querySelector('button[data-action="delete"]').click();
await tick(20);
ok('Delete asks first, naming the file', asked === 'Delete ' + ODD + '?', asked);
ok('cancel sends nothing', !requests.some(r => r.method === 'DELETE'));
window.confirm = () => true;
before = lists();
rows()[1].querySelector('button[data-action="delete"]').click();
await tick(20);
const deletes = requests.filter(r => r.method === 'DELETE');
ok('one DELETE request, to the encoded name', deletes.length === 1
&& deletes[0].url === '/api/v3/backup/' + encodeURIComponent(ODD), deletes.map(d => d.url));
ok('the list reloads once', lists() === before + 1, lists() - before);
ok('the deletion is reported', notes.some(n => n[0] === 'Backup deleted' && n[1] === 'success'), notes);
// ── export ──────────────────────────────────────────────────────────────
before = lists();
action('export').click();
action('export').click(); // a second click while it is busy
ok('the export button is busy while it runs', $('export-backup-btn').disabled
&& $('export-backup-btn').textContent.includes('Creating'));
await tick(30);
ok('one export request', count('/api/v3/backup/export', 'POST') === 1, count('/api/v3/backup/export', 'POST'));
ok('the new backup is reported', notes.some(n => n[0] === 'Backup created: ' + ZIP), notes);
ok('its download starts once', navigations === 1, navigations);
ok('the list reloads once', lists() === before + 1, lists() - before);
ok('the button comes back', !$('export-backup-btn').disabled
&& $('export-backup-btn').textContent.trim() === 'Download backup', $('export-backup-btn').textContent);
// ── inspect ─────────────────────────────────────────────────────────────
notes.length = 0;
action('inspect').click();
await tick(20);
ok('Inspect with no file asks for one', notes[0] && notes[0][0] === 'Choose a backup file first', notes);
ok('...and sends nothing', count('/api/v3/backup/validate', 'POST') === 0);
const file = new window.File(['PK'], 'backup.zip', { type: 'application/zip' });
pick(file);
action('inspect').click();
await tick(20);
const validate = requests.filter(r => r.url === '/api/v3/backup/validate');
ok('one validate request', validate.length === 1, validate.length);
ok('it uploads the file as backup_file', validate[0] && validate[0].body instanceof window.FormData
&& validate[0].body.get('backup_file').name === 'backup.zip');
ok('the contents are shown', !hidden('restore-preview')
&& /Includes: config, secrets/.test($('restore-preview-body').textContent), $('restore-preview-body').textContent);
ok('a hostile host name and plugin id are text', $('restore-preview-body').textContent.includes(HOSTILE)
&& !doc.querySelector('#restore-preview-body img'));
pick(new window.File(['PK2'], 'other.zip'));
ok('picking another file hides the old contents', hidden('restore-preview'));
notes.length = 0;
window.confirm = () => true;
action('restore').click();
await tick(20);
ok('Restore needs an inspected file', notes[0] && notes[0][0] === 'Inspect the file before restoring'
&& count('/api/v3/backup/restore', 'POST') === 0, notes);
// ── restore ─────────────────────────────────────────────────────────────
pick(file);
action('inspect').click();
await tick(20);
$('opt-secrets').checked = false;
restoreAnswer = () => Promise.resolve({
status: 200, ok: true, headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify({ status: 'success', data: {
success: true, restored: ['config', HOSTILE], skipped: ['secrets'], plugins_installed: [], plugins_failed: [], errors: [],
} })),
});
window.confirm = () => false;
action('restore').click();
await tick(20);
ok('Restore asks first', count('/api/v3/backup/restore', 'POST') === 0);
window.confirm = () => true;
notes.length = 0;
action('restore').click();
await tick(30);
const restores = requests.filter(r => r.url === '/api/v3/backup/restore');
ok('one restore request', restores.length === 1, restores.length);
const options = restores[0] && JSON.parse(restores[0].body.get('options'));
ok('it sends exactly the six options, as booleans', options && Object.keys(options).sort().join() ===
'reinstall_plugins,restore_config,restore_fonts,restore_plugin_uploads,restore_secrets,restore_wifi'
&& options.restore_secrets === false && options.restore_config === true, options);
ok('...with the inspected file', restores[0] && restores[0].body.get('backup_file').name === 'backup.zip');
ok('a restore is not cancelled by a swap', restores[0] && restores[0].signal === undefined);
ok('the result is drawn', !hidden('restore-result') && /Restore complete/.test($('restore-result').textContent)
&& /Skipped: secrets/.test($('restore-result').textContent) && /Restart the display service/.test($('restore-result').textContent),
$('restore-result').textContent);
ok('a hostile restored name is text', $('restore-result').textContent.includes(HOSTILE) && !doc.querySelector('#restore-result img'));
ok('...in green, reported once', $('restore-result').classList.contains('bg-green-50')
&& notes.length === 1 && notes[0][0] === 'Restore complete' && notes[0][1] === 'success', notes);
ok('the restore button comes back', !$('run-restore-btn').disabled && $('run-restore-btn').textContent.trim() === 'Restore now');
restoreAnswer = () => Promise.resolve({
status: 500, ok: false, headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify({ status: 'error', message: 'Restore incomplete — failed: secrets',
data: { errors: ['secrets'] } })),
});
notes.length = 0;
action('restore').click();
await tick(30);
ok('a failed restore reports the server\'s message', notes[0] && notes[0][0] === 'Restore failed: Restore incomplete — failed: secrets'
&& notes[0][1] === 'error', notes);
action('cancel').click();
ok('Cancel hides the contents and forgets the file', hidden('restore-preview') && hidden('restore-result')
&& $('restore-file-input').value === '');
notes.length = 0;
action('restore').click();
await tick(20);
ok('...so Restore asks for an inspection again', notes[0] && notes[0][0] === 'Inspect the file before restoring', notes);
// ── reads in flight are cancelled by a swap ─────────────────────────────
mode = 'hang';
action('refresh').click();
await tick(5);
mode = 'ok';
await swap();
ok('the swap cancelled the list request', aborted === 1, aborted);
pending.forEach(resolve => resolve());
await tick(20);
ok('the new page drew its own list', rows().length === 2, rows().length);
// ── the old globals ─────────────────────────────────────────────────────
before = lists();
await backupPage.loadBackupList();
ok('loadBackupList() reloads once', lists() === before + 1, lists() - before);
notes.length = 0;
await backupPage.validateRestoreFile();
ok('validateRestoreFile() inspects', notes[0] && notes[0][0] === 'Choose a backup file first', notes);
const exportsBefore = count('/api/v3/backup/export', 'POST');
await backupPage.exportBackup();
ok('exportBackup() exports once', count('/api/v3/backup/export', 'POST') === exportsBefore + 1);
ok('clearRestore and runRestore are there', typeof backupPage.clearRestore === 'function'
&& typeof backupPage.runRestore === 'function');
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+178
View File
@@ -0,0 +1,178 @@
// The Rotation & Durations tab as a page module
// (static/v3/js/pages/durations.js), in a real DOM (jsdom) with the real
// server-rendered partial, the real plugin-order-list widget and the real
// /api/v3/plugins/installed payload shape. Built like test_cache_page.js:
//
// * the partial ships no <script>; its root is data-page="durations"
// * the rotation list loads once per swap-in, however many swaps came first
// * a list request still in flight when the page is swapped away is
// cancelled and draws nothing
// * a widget that loads late is waited for, and a page swapped away while
// waiting starts nothing
// * plugin names reach the page as text
const http = require('http');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/durations');
const real = JSON.parse(await get('/api/v3/plugins/installed'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const durationsPage = await load('pages/durations.js');
const widgetSource = fs.readFileSync(path.join(JS, 'widgets/plugin-order-list.js'), 'utf8');
console.log('\n── Rotation & Durations tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial root is data-page="durations"', /data-page="durations"/.test(partial));
ok('the rotation list and its hidden input are in the partial',
/id="rotation_plugin_order"/.test(partial) && /id="rotation_plugin_order_value"/.test(partial));
ok('the real API answers in the shape the widget reads',
real.status === 'success' && real.data && Array.isArray(real.data.plugins), real);
const HOSTILE = '<img src=x onerror="window.pwned=1">';
const plugins = [
{ id: 'clock', name: 'Clock', enabled: true },
{ id: 'weather', name: HOSTILE, enabled: true },
{ id: 'stocks', name: 'Stocks', enabled: true },
{ id: 'off', name: 'Disabled one', enabled: false },
];
const errs = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => errs.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="durations-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('durations-content');
let mode = 'ok';
const requests = [];
const pending = [];
let aborted = 0;
function fakeFetch(url, init = {}) {
requests.push({ url, method: init.method || 'GET' });
const respond = (status, body) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: () => null },
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
});
if (mode === 'hang') {
return new Promise((resolve, reject) => {
pending.push(resolve);
if (init.signal) init.signal.addEventListener('abort', () => {
aborted++;
const e = new Error('aborted'); e.name = 'AbortError'; reject(e);
});
});
}
return respond(200, { status: 'success', data: { plugins } });
}
// The widget is a classic script: it calls the window's own fetch.
window.fetch = fakeFetch;
window.eval(widgetSource);
const widget = window.PluginOrderList;
ok('the widget script defines PluginOrderList', !!(widget && widget.init));
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: () => {} },
});
registry.register('durations', durationsPage);
const lists = () => requests.filter(r => r.url === '/api/v3/plugins/installed').length;
const $ = id => doc.getElementById(id);
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]');
async function swap() {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = partial;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('one plugin-list request on start', lists() === 1, lists());
let rows = doc.querySelectorAll('#rotation_plugin_order .plugin-order-item');
ok('one row per enabled plugin', rows.length === 3, rows.length);
ok('the hidden input holds the order', order().length === 3 && order().includes('clock'), order());
ok('a hostile plugin name is shown as text', $('rotation_plugin_order').textContent.includes(HOSTILE));
ok('...and created no element', !doc.querySelector('#rotation_plugin_order img') && !window.pwned);
// ── repeated swaps ──────────────────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap();
ok('one plugin-list request per swap', lists() === 6, lists());
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
rows = doc.querySelectorAll('#rotation_plugin_order .plugin-order-item');
ok('the list is drawn once, not stacked', rows.length === 3, rows.length);
const before = order();
const down = rows[0].querySelector('button[aria-label^="Move"][aria-label$="down"]');
down.click();
await tick(5);
const after = order();
ok('Move down moves one place (one listener)',
after[0] === before[1] && after[1] === before[0] && after[2] === before[2], [before, after]);
ok('reordering makes no request', lists() === 6, lists());
// ── in flight when swapped away ─────────────────────────────────────────
mode = 'hang';
await swap();
ok('a request is in flight', pending.length === 1, pending.length);
mode = 'ok';
await swap();
await tick(20);
ok('the new page drew its own list', doc.querySelectorAll('#rotation_plugin_order .plugin-order-item').length === 3);
ok('the cancelled request drew no error', !/Error loading plugins/.test($('rotation_plugin_order').textContent));
ok('the swap cancelled the request (ctx.signal reaches the widget)', aborted === 1, aborted);
// Answer it late anyway, with one plugin: the new page's list is untouched.
pending[0]({ status: 200, ok: true, headers: { get: () => null },
json: () => Promise.resolve({ status: 'success', data: { plugins: [plugins[0]] } }) });
await tick(20);
ok('a late answer to the cancelled request redraws nothing',
doc.querySelectorAll('#rotation_plugin_order .plugin-order-item').length === 3,
doc.querySelectorAll('#rotation_plugin_order .plugin-order-item').length);
// ── the widget loads late ───────────────────────────────────────────────
delete window.PluginOrderList;
const beforeLate = lists();
await swap();
ok('no request while the widget is missing', lists() === beforeLate, lists() - beforeLate);
window.PluginOrderList = widget;
await tick(150);
ok('starts once the widget arrives', lists() === beforeLate + 1, lists() - beforeLate);
delete window.PluginOrderList;
await swap();
const beforeGone = lists();
// Swapped to another tab's content while still waiting for the widget.
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = '<p>another tab</p>';
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
window.PluginOrderList = widget;
await tick(250);
ok('a page swapped away while waiting starts nothing', lists() === beforeGone, lists() - beforeGone);
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+231
View File
@@ -0,0 +1,231 @@
// The Operation History tab as a page module
// (static/v3/js/pages/operation-history.js), in a real DOM (jsdom) with the
// real server-rendered partial and the real API's payload shape. Built like
// test_cache_page.js:
//
// * the partial ships no <script>; its root is data-page="operation-history"
// * one history request per swap-in, and after five swaps Refresh, Clear,
// Next and the filters each act exactly once
// * the plugin filter is filled once per swap-in, not once per swap so far
// * a request in flight when the page is swapped away is cancelled
// * plugin ids, users and error messages from the log are shown as text
const http = require('http');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/operation-history');
const real = JSON.parse(await get('/api/v3/plugins/operation/history?limit=1000'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const historyPage = await load('pages/operation-history.js');
console.log('\n── Operation History tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial root is data-page="operation-history"', /data-page="operation-history"/.test(partial));
ok('the real API answers in the shape the page reads',
real.status === 'success' && Array.isArray(real.data), real);
const realKeys = real.data.length ? Object.keys(real.data[0]) : null;
ok('a real record has the fields the page reads',
!realKeys || ['operation_type', 'plugin_id', 'status', 'timestamp'].every(k => realKeys.includes(k)), realKeys);
const HOSTILE = '<img src=x onerror="window.pwned=1">';
function record(i, extra) {
return Object.assign({
operation_id: 'op' + i, operation_type: ['install', 'update', 'enable'][i % 3],
plugin_id: i % 2 ? 'clock' : 'weather', status: i % 4 === 0 ? 'error' : 'completed',
user: 'web', timestamp: 1790000000 * 1000 - i * 60000, details: { version: '1.' + i },
}, extra || {});
}
let records = [
record(0, { plugin_id: HOSTILE, error: HOSTILE + ' went wrong', user: HOSTILE,
details: { commit: 'abcdef1234', previous_commit: '1234567890' } }),
].concat(Array.from({ length: 119 }, (_, i) => record(i + 1)));
const errs = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => errs.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="operation-history-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('operation-history-content');
let mode = 'ok';
let aborted = 0;
const requests = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method });
const respond = (status, body, headers = {}) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: n => headers[n] || null },
text: () => Promise.resolve(JSON.stringify(body)),
});
if (url === '/api/v3/plugins/installed') {
return respond(200, { status: 'success', data: { plugins: [{ id: 'clock' }, { id: 'weather' }, { id: HOSTILE }] } });
}
if (init.method === 'DELETE') return respond(200, { status: 'success', message: 'Operation history cleared' });
if (mode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (mode === 'error') return respond(500, { status: 'error', message: 'Operation history not initialized' });
if (mode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
if (mode === 'hang') {
return new Promise((resolve, reject) => {
init.signal.addEventListener('abort', () => {
aborted++;
const e = new Error('aborted'); e.name = 'AbortError'; reject(e);
});
});
}
return respond(200, { status: 'success', data: records });
}
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
});
registry.register('operation-history', historyPage);
const count = (url, method = 'GET') => requests.filter(r => r.url === url && r.method === method).length;
const HISTORY = '/api/v3/plugins/operation/history?limit=1000';
const histories = () => count(HISTORY);
const $ = id => doc.getElementById(id);
const rows = () => doc.querySelectorAll('#history-table-body tr');
const showing = () => [$('history-start').textContent, $('history-end').textContent, $('history-total').textContent].join('/');
async function swap() {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = partial;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
function change(id, value) {
$(id).value = value;
$(id).dispatchEvent(new window.Event('change', { bubbles: true }));
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('one history request on start', histories() === 1, histories());
ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1);
ok('the first page holds 50 rows', rows().length === 50, rows().length);
ok('the counters say 1 to 50 of 120', showing() === '1/50/120', showing());
ok('Previous is off on the first page', $('history-prev-btn').disabled && !$('history-next-btn').disabled);
const first = rows()[0];
ok('a hostile plugin id, user and error are shown as text',
first.textContent.includes(HOSTILE) && first.textContent.includes(HOSTILE + ' went wrong'));
ok('...and created no element', !doc.querySelector('#history-table-body img') && !window.pwned);
ok('a hostile plugin id in the filter is text too',
[...$('history-plugin-filter').options].some(o => o.value === HOSTILE && o.textContent === HOSTILE));
ok('details are summarised', first.textContent.includes('commit: abcdef1') && first.textContent.includes('from: 1234567'));
ok('"error" is shown as failed, in red',
first.querySelectorAll('span')[1].textContent === 'failed' && first.querySelector('.bg-red-100'));
// ── repeated swaps ──────────────────────────────────────────────────────
const oldRefresh = $('refresh-history-btn');
for (let i = 0; i < 5; i++) await swap();
ok('one history request per swap', histories() === 6, histories());
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('the plugin filter is filled once', $('history-plugin-filter').options.length === 4,
$('history-plugin-filter').options.length);
let before = histories();
$('refresh-history-btn').click();
await tick(20);
ok('Refresh makes exactly one request (no duplicate listeners)', histories() === before + 1, histories() - before);
oldRefresh.click();
await tick(20);
ok('a swapped-out button does nothing', histories() === before + 1, histories() - before);
$('history-next-btn').click();
ok('Next moves one page', showing() === '51/100/120', showing());
$('history-next-btn').click();
ok('...and the last page is short', showing() === '101/120/120' && rows().length === 20, showing());
ok('Next is off on the last page', $('history-next-btn').disabled);
$('refresh-history-btn').click();
await tick(20);
ok('Refresh keeps the page', showing() === '101/120/120', showing());
$('history-prev-btn').click();
ok('Previous moves one page', showing() === '51/100/120', showing());
// ── filters ─────────────────────────────────────────────────────────────
change('history-status-filter', 'failed');
ok('the status filter matches "error" records as failed', showing() === '1/30/30', showing());
change('history-status-filter', '');
change('history-plugin-filter', 'weather');
ok('the plugin filter', [...rows()].every(r => r.children[2].textContent === 'weather') && showing() === '1/50/59', showing());
change('history-plugin-filter', '');
$('history-search').value = 'no such thing';
$('history-search').dispatchEvent(new window.Event('input', { bubbles: true }));
ok('search waits for the typing to stop', rows().length === 50);
await tick(350);
ok('a search with no match says so', rows().length === 1 && rows()[0].textContent.includes('No operations found'));
ok('...and the counters follow', showing() === '0/0/0', showing());
$('history-search').value = '';
$('history-search').dispatchEvent(new window.Event('input', { bubbles: true }));
await swap(); // swapped away with the search still pending
await tick(350);
ok('a pending search on a swapped-out page does nothing', errs.length === 0, errs);
// ── clear ───────────────────────────────────────────────────────────────
let asked = null;
window.confirm = msg => { asked = msg; return false; };
$('clear-history-btn').click();
await tick(20);
ok('Clear asks first', asked && /clear the operation history/.test(asked), asked);
ok('cancel sends nothing', count('/api/v3/plugins/operation/history', 'DELETE') === 0);
window.confirm = () => true;
$('clear-history-btn').click();
await tick(20);
ok('one DELETE request', count('/api/v3/plugins/operation/history', 'DELETE') === 1,
count('/api/v3/plugins/operation/history', 'DELETE'));
ok('the table empties', rows().length === 1 && showing() === '0/0/0', showing());
// ── states ──────────────────────────────────────────────────────────────
notes.length = 0;
mode = 'error';
$('refresh-history-btn').click(); await tick(20);
ok('an API error is reported', notes.length === 1 && notes[0][0] === 'Failed to load operation history' && notes[0][1] === 'error', notes);
mode = 'network';
$('refresh-history-btn').click(); await tick(20);
ok('a network failure is reported', notes.length === 2 && notes[1][0] === 'Error loading operation history', notes);
mode = 'login';
$('refresh-history-btn').click(); await tick(20);
ok('the login redirect reports nothing', notes.length === 2, notes);
// ── in flight when swapped away ─────────────────────────────────────────
mode = 'hang';
$('refresh-history-btn').click(); await tick(5);
mode = 'ok';
await swap();
ok('the swap cancelled the request', aborted === 1, aborted);
ok('the new page drew its own list', rows().length === 50 && notes.length === 2, [rows().length, notes]);
// ── PluginAPI's cached installed list, when it is loaded ────────────────
let pluginApiCalls = 0;
window.PluginAPI = { getInstalledPlugins: () => { pluginApiCalls++; return Promise.resolve([{ id: 'cached' }]); } };
const directBefore = count('/api/v3/plugins/installed');
await swap();
ok('the plugin filter uses PluginAPI when it is there', pluginApiCalls === 1
&& count('/api/v3/plugins/installed') === directBefore, [pluginApiCalls, count('/api/v3/plugins/installed') - directBefore]);
ok('...and is filled from it', [...$('history-plugin-filter').options].map(o => o.value).join() === ',cached');
delete window.PluginAPI;
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+187
View File
@@ -0,0 +1,187 @@
// The Config Editor tab as a page module (static/v3/js/pages/raw-json.js), in
// a real DOM (jsdom) with the real server-rendered partial and the real
// endpoints' answers. Built like test_cache_page.js:
//
// * the partial ships no <script> and no onclick; its root is
// data-page="raw-json" and its buttons name an action and an editor
// * after five swaps, each Save makes exactly one request and each
// Format / Validate acts once
// * invalid JSON is never sent, and the parser's message is shown as text
// * a save is a write: a swap does not cancel it, and its result is still
// reported
// * the old globals' entry points still work
const http = require('http');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/raw-json');
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const rawPage = await load('pages/raw-json.js');
console.log('\n── Config Editor tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline handlers', !/onclick=/i.test(partial));
ok('the partial root is data-page="raw-json"', /data-page="raw-json"/.test(partial));
ok('six buttons name an action and an editor',
(partial.match(/data-action="(format|validate|save)" data-editor="(main|secrets)"/g) || []).length === 6);
const errs = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => errs.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="config-editor-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('config-editor-content');
let mode = 'ok';
const requests = [];
const pending = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method, body: init.body, signal: init.signal });
const respond = (status, body) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify(body)),
});
if (mode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (mode === 'error') return respond(400, { status: 'error', message: 'Configuration must be a JSON object' });
if (mode === 'hang') return new Promise(resolve => pending.push(() => resolve(respond(200, { status: 'success' }))));
return respond(200, { status: 'success', message: 'Main configuration saved successfully' });
}
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
});
registry.register('raw-json', rawPage);
const $ = id => doc.getElementById(id);
const button = (action, editor) => doc.querySelector(`button[data-action="${action}"][data-editor="${editor}"]`);
const posts = url => requests.filter(r => r.url === url && r.method === 'POST');
async function swap() {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = partial;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
function type(id, text) {
$(id).value = text;
$(id).dispatchEvent(new window.Event('input', { bubbles: true }));
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('the real files parse', (() => { try { JSON.parse($('main-config-editor').value); JSON.parse($('secrets-config-editor').value); return true; } catch (e) { return false; } })());
ok('both editors are validated on start',
$('main-config-validation').textContent.trim() === 'Valid JSON' && $('secrets-config-validation').textContent.trim() === 'Valid JSON',
[$('main-config-validation').textContent, $('secrets-config-validation').textContent]);
ok('nothing is requested on start', requests.length === 0, requests.length);
// ── repeated swaps ──────────────────────────────────────────────────────
const oldSave = button('save', 'main');
for (let i = 0; i < 5; i++) await swap();
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
type('main-config-editor', '{"display": {"brightness": 50}, "note": "a\\"b"}');
button('save', 'main').click();
await tick(20);
ok('Save makes exactly one request (no duplicate listeners)', posts('/api/v3/config/raw/main').length === 1,
posts('/api/v3/config/raw/main').length);
const sent = posts('/api/v3/config/raw/main')[0];
ok('it posts the parsed file as JSON', sent && JSON.parse(sent.body).note === 'a"b' && JSON.parse(sent.body).display.brightness === 50);
ok('success is reported once', notes.length === 1 && notes[0][0] === 'config.json saved successfully!' && notes[0][1] === 'success', notes);
oldSave.click();
await tick(20);
ok('a swapped-out button does nothing', posts('/api/v3/config/raw/main').length === 1);
button('save', 'secrets').click();
await tick(20);
ok('Save on the secrets editor posts to the secrets endpoint, once', posts('/api/v3/config/raw/secrets').length === 1);
ok('...and names that file', notes[1] && notes[1][0] === 'config_secrets.json saved successfully!', notes);
// ── format and validate ─────────────────────────────────────────────────
notes.length = 0;
type('main-config-editor', '{"a":1,"b":[1,2]}');
button('format', 'main').click();
ok('Format re-indents by four spaces', $('main-config-editor').value === '{\n "a": 1,\n "b": [\n 1,\n 2\n ]\n}',
$('main-config-editor').value);
ok('...and says so once', notes.length === 1 && notes[0][0] === 'JSON formatted successfully!', notes);
button('validate', 'main').click();
ok('Validate shows the detailed box', $('main-config-validation').textContent.includes('JSON is valid!'));
ok('...and says so once', notes.length === 2 && notes[1][0] === 'JSON validation successful!', notes);
// ── invalid JSON ────────────────────────────────────────────────────────
const HOSTILE = '{"x": <img src=x onerror="window.pwned=1">';
type('main-config-editor', HOSTILE);
ok('typing re-validates', /^Invalid JSON: /.test($('main-config-validation').textContent.trim()),
$('main-config-validation').textContent);
ok('the parser message is text, not markup', !doc.querySelector('#main-config-validation img') && !window.pwned);
notes.length = 0;
const postsBefore = requests.length;
button('save', 'main').click();
await tick(20);
ok('invalid JSON is not sent', requests.length === postsBefore);
ok('...and the user is told', notes.length === 1 && notes[0][0] === 'Invalid JSON! Please fix errors before saving.', notes);
button('format', 'main').click();
ok('Format refuses invalid JSON', $('main-config-editor').value === HOSTILE && /^Cannot format invalid JSON/.test(notes[1][0]), notes);
button('validate', 'main').click();
ok('Validate shows the error box', $('main-config-validation').textContent.includes('Invalid JSON syntax')
&& !doc.querySelector('#main-config-validation img'));
// ── server answers ──────────────────────────────────────────────────────
type('main-config-editor', '{"a": 1}');
notes.length = 0;
mode = 'error';
button('save', 'main').click(); await tick(20);
ok('a refused save shows the server\'s message', notes[0] && notes[0][0] === 'Error saving config.json: Configuration must be a JSON object', notes);
mode = 'network';
button('save', 'main').click(); await tick(20);
ok('a network failure says so', notes[1] && notes[1][0] === 'Error saving config.json: Failed to fetch', notes);
// ── a save is a write: a swap does not cancel it ────────────────────────
mode = 'hang';
notes.length = 0;
button('save', 'main').click(); await tick(5);
const inFlight = requests[requests.length - 1];
ok('the save carries no page signal', inFlight.signal === undefined);
mode = 'ok';
await swap();
pending.forEach(resolve => resolve());
await tick(20);
ok('its result is still reported after the swap', notes.length === 1 && notes[0][1] === 'success', notes);
// ── the old globals ─────────────────────────────────────────────────────
type('main-config-editor', '[1,2]');
ok('validateJSON(editorId) answers synchronously', rawPage.validateJSON('main-config-editor') === true);
type('main-config-editor', '[1,');
ok('...false for invalid JSON', rawPage.validateJSON('main-config-editor') === false);
type('secrets-config-editor', '{"k":"v"}');
rawPage.formatJson('secrets-config-editor', 'secrets-config-validation');
ok('formatJson(editorId, validationId) formats that editor', $('secrets-config-editor').value === '{\n "k": "v"\n}');
const before = posts('/api/v3/config/raw/secrets').length;
await rawPage.saveSecretsConfig();
ok('saveSecretsConfig() saves once', posts('/api/v3/config/raw/secrets').length === before + 1);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+3 -1
View File
@@ -25,7 +25,9 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
'unit/test_page_registry.js', 'unit/test_core_modules.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js', 'dom/test_cache_page.js'];
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js'];
function reachable(url) {
return new Promise(res => {
+4
View File
@@ -51,6 +51,10 @@ async function rejection(promise) {
const controller = new AbortController();
await api.get('/api/v3/x', { signal: controller.signal });
ok('the signal is passed to fetch', calls[2][1].signal === controller.signal);
const upload = { kind: 'form-data' };
await api.request('POST', '/api/v3/backup/validate', { body: upload });
ok('a raw body (an upload) is sent as it is, with no JSON Content-Type',
calls[3][1].body === upload && calls[3][1].headers['Content-Type'] === undefined, calls[3][1].headers);
// Default: window.fetch looked up per call, so base.html's login wrapper
// (installed before any module runs, or replaced later) is the one used.
+3 -6
View File
@@ -105,18 +105,15 @@ const ESCAPERS = [
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
['display.html (escapeAttr)',
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
['backup_restore.html (escapeHtml)',
'templates/v3/partials/backup_restore.html', 'function escapeHtml(value) {', 'escapeHtml', false],
['operation_history.html (escapeHtml)',
'templates/v3/partials/operation_history.html', 'function escapeHtml(text) {', 'escapeHtml', false],
['tools.html (escHtml)',
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
['tools.html (phEscape)',
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
['logs.html (escapeHtml)',
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
// cache.html has no script any more: js/pages/cache.js builds its rows with
// textContent, and test/js/dom/test_cache_page.js checks a hostile key.
// cache.html, backup_restore.html and operation_history.html have no
// script any more: their js/pages/ modules draw server data with
// textContent, and each page's suite in test/js/dom/ checks a hostile value.
];
// The breakout payload: closes a double-quoted attribute and opens an event
+67 -4
View File
@@ -41,7 +41,9 @@ def _url(path):
def test_the_module_directories_hold_modules():
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js"}
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js",
"durations.js", "operation-history.js", "raw-json.js",
"backup-restore.js"}
for directory in MODULE_DIRS:
# node needs this to import them in the JS tests; browsers ignore it.
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
@@ -94,10 +96,71 @@ def test_every_import_resolves_inside_the_module_tree(module):
f"{module.name}: {spec!r} leaves js/core and js/pages")
# Converted so far: page name -> (its partial, the route that serves it). The
# migration order is in docs/WEB_FRONTEND_ARCHITECTURE.md.
CONVERTED = {
"cache": ("cache.html", "/partials/cache"),
"durations": ("durations.html", "/partials/durations"),
"operation-history": ("operation_history.html", "/partials/operation-history"),
"raw-json": ("raw_json.html", "/partials/raw-json"),
"backup-restore": ("backup_restore.html", "/partials/backup-restore"),
}
# Old window.* names that moved into a page module. Each stays as a
# deprecated alias in boot.js that forwards to the module's export.
ALIASES = {
"cache": ["deleteCacheFile"],
"raw-json": ["formatJson", "manualValidateJson", "validateJSON",
"saveMainConfig", "saveSecretsConfig"],
"backup-restore": ["exportBackup", "loadBackupList", "validateRestoreFile",
"clearRestore", "runRestore"],
}
def _boot():
return (JS / "core" / "boot.js").read_text(encoding="utf-8")
def _registered_pages():
boot = (JS / "core" / "boot.js").read_text(encoding="utf-8")
return re.findall(r"registry\.register\('([\w-]+)',\s*function\(\)\s*\{\s*return import\('\.\./pages/([\w-]+)\.js'\)",
boot)
return re.findall(r"'([\w-]+)':\s*page\(function\(\)\s*\{\s*return import\('\.\./pages/([\w-]+)\.js'\)",
_boot())
def test_every_converted_page_is_registered():
assert dict(_registered_pages()) == {name: name for name in CONVERTED}
@pytest.mark.parametrize("name", sorted(CONVERTED))
def test_a_converted_partial_roots_its_page(client, name):
partial, route = CONVERTED[name]
text = (PARTIALS / partial).read_text(encoding="utf-8")
assert f'data-page="{name}"' in text
assert "<script" not in text.lower()
# Its buttons are wired by the module, not by inline handlers naming
# globals (which would reach the code only through a deprecated alias).
assert "onclick=" not in text.lower()
module = (JS / "pages" / f"{name}.js").read_text(encoding="utf-8")
assert re.search(r"^export function init\(root, ctx\)", module, re.M)
# Rendered by the real route, the root is there exactly once.
resp = client.get(route)
assert resp.status_code == 200, route
assert resp.get_data(as_text=True).count(f'data-page="{name}"') == 1, route
@pytest.mark.parametrize("name", sorted(ALIASES))
def test_moved_globals_stay_as_aliases(name):
boot = _boot()
module = (JS / "pages" / f"{name}.js").read_text(encoding="utf-8")
for global_name in ALIASES[name]:
assert f"'{global_name}'" in boot, f"boot.js does not alias {global_name}"
assert re.search(rf"^export (?:function|const) {global_name}\b", module, re.M), (
f"pages/{name}.js does not export {global_name}")
# No template defines them any more.
for partial in PARTIALS.glob("*.html"):
text = partial.read_text(encoding="utf-8")
for global_name in ALIASES[name]:
assert f"window.{global_name} =" not in text, partial.name
assert f"function {global_name}(" not in text, partial.name
def test_every_registered_page_has_its_module_and_partial():
+5
View File
@@ -4,6 +4,7 @@
* const api = createApi();
* const body = await api.get('/api/v3/cache/list', { signal });
* await api.post('/api/v3/cache/delete', { key }, { signal });
* await api.request('POST', '/api/v3/backup/validate', { body: formData });
*
* Every call resolves to the parsed JSON body, or rejects with an ApiError:
* error.status the HTTP status (0 when no HTTP answer arrived)
@@ -77,6 +78,10 @@ export function createApi(options = {}) {
if (opts.json !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(opts.json);
} else if (opts.body !== undefined) {
// Sent as it is (a FormData upload, say); the browser sets the
// Content-Type, multipart boundary included.
init.body = opts.body;
}
let response;
+40 -4
View File
@@ -21,13 +21,49 @@ const registry = createRegistry({
});
const facade = installFacade(window, createFacade(window, api, registry));
/**
* A page's loader. It remembers the module once loaded, so an alias of a
* synchronous old global (validateJSON returns a boolean) can answer
* synchronously while its page is on screen.
*/
function page(load) {
let module = null;
const loader = function() {
return Promise.resolve(load()).then(function(loaded) { module = loaded; return loaded; });
};
loader.loaded = function() { return module; };
return loader;
}
// Converted pages. Each loads on first use: its module is fetched only when
// its partial (data-page="<name>") first appears.
registry.register('cache', function() { return import('../pages/cache.js'); });
const pages = {
'cache': page(function() { return import('../pages/cache.js'); }),
'durations': page(function() { return import('../pages/durations.js'); }),
'operation-history': page(function() { return import('../pages/operation-history.js'); }),
'raw-json': page(function() { return import('../pages/raw-json.js'); }),
'backup-restore': page(function() { return import('../pages/backup-restore.js'); }),
};
Object.keys(pages).forEach(function(name) { registry.register(name, pages[name]); });
/** Keep window[name] working: forward to the page module's export of the same name. */
function alias(pageName, name, replacement) {
const loader = pages[pageName];
facade.deprecate(name, function() {
const args = arguments;
const module = loader.loaded();
if (module) return module[name].apply(null, args);
return loader().then(function(loaded) { return loaded[name].apply(null, args); });
}, replacement);
}
// Old globals the converted pages used to define.
facade.deprecate('deleteCacheFile', function(key) {
return import('../pages/cache.js').then(function(page) { return page.deleteCacheFile(key); });
}, "the Cache tab's Delete buttons");
alias('cache', 'deleteCacheFile', "the Cache tab's Delete buttons");
['formatJson', 'manualValidateJson', 'validateJSON', 'saveMainConfig', 'saveSecretsConfig'].forEach(function(name) {
alias('raw-json', name, "the Config Editor tab's buttons");
});
['exportBackup', 'loadBackupList', 'validateRestoreFile', 'clearRestore', 'runRestore'].forEach(function(name) {
alias('backup-restore', name, "the Backup & Restore tab's buttons");
});
registry.start();
@@ -0,0 +1,373 @@
/*
* pages/backup-restore.js -- the Backup & Restore tab
* (templates/v3/partials/backup_restore.html).
*
* The buttons carry data-action; one delegated listener on the page root
* handles them all, including each history row's Delete. Everything the
* server sends back (file names, host names, plugin ids, error messages) is
* drawn with textContent.
*
* Reads (the summary, the history list, inspecting a file) carry ctx.signal
* and are cancelled when the page is swapped away. Writes (export, delete,
* restore) are not: the server always finishes them and the result is still
* reported in a notification, but nothing is drawn into a page that is gone.
*
* The old globals (exportBackup, loadBackupList, validateRestoreFile,
* clearRestore, runRestore) are deprecated aliases made in core/boot.js; they
* forward to the exports at the bottom of this file.
*/
const URLS = {
preview: '/api/v3/backup/preview',
list: '/api/v3/backup/list',
exportZip: '/api/v3/backup/export',
validate: '/api/v3/backup/validate',
restore: '/api/v3/backup/restore',
download: '/api/v3/backup/download/',
remove: '/api/v3/backup/',
};
// Checkbox id -> RestoreOptions key (backup.py's _RESTORE_OPTION_KEYS).
const RESTORE_OPTIONS = [
['opt-config', 'restore_config'],
['opt-secrets', 'restore_secrets'],
['opt-wifi', 'restore_wifi'],
['opt-fonts', 'restore_fonts'],
['opt-plugin-uploads', 'restore_plugin_uploads'],
['opt-reinstall', 'reinstall_plugins'],
];
// The mounted page, for the deprecated aliases (boot.js).
let active = null;
function isQuiet(error) {
return !!error && (error.name === 'AbortError' || error.loginRequired);
}
function message(error) {
return (error && error.message) || 'Unknown error';
}
export function formatSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
function el(doc, tag, className, text) {
const node = doc.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
/** "<strong>label</strong> value" as one line. */
function labelled(doc, tag, label, value) {
const line = el(doc, tag);
line.append(el(doc, 'strong', null, label), doc.createTextNode(' ' + value));
return line;
}
function list(values, map, separator) {
return (values || []).map(map || String).join(separator || ', ');
}
function setBusy(button, busy, idleIcon, idleText, busyText) {
const doc = button.ownerDocument;
button.disabled = busy;
button.replaceChildren(el(doc, 'i', busy ? 'fas fa-spinner fa-spin mr-2' : 'fas ' + idleIcon + ' mr-2'),
doc.createTextNode(busy ? busyText : idleText));
}
function elements(root) {
const $ = function(id) { return root.querySelector('#' + id); };
return {
preview: $('export-preview'),
exportButton: $('export-backup-btn'),
fileInput: $('restore-file-input'),
restorePreview: $('restore-preview'),
restorePreviewBody: $('restore-preview-body'),
restoreButton: $('run-restore-btn'),
result: $('restore-result'),
history: $('backup-history'),
};
}
/** The "what a new backup would hold" summary on the Export card. */
export function loadPreview(ctx) {
const box = ctx.state.els.preview;
const doc = ctx.root.ownerDocument;
return ctx.api.get(URLS.preview, { signal: ctx.signal }).then(function(body) {
const d = body.data || {};
const fonts = Array.isArray(d.user_fonts) ? d.user_fonts : [];
const ul = el(doc, 'ul', 'list-disc pl-5 space-y-1');
function item(label, value, after) {
const li = el(doc, 'li', null, label + ': ');
li.append(el(doc, 'strong', null, String(value)));
if (after) li.append(doc.createTextNode(' ' + after));
ul.append(li);
}
item('Main config', d.has_config ? 'yes' : 'no');
item('Secrets', d.has_secrets ? 'yes' : 'no');
item('WiFi config', d.has_wifi ? 'yes' : 'no');
item('User fonts', fonts.length, fonts.length ? '(' + list(fonts) + ')' : '');
item('Plugin image uploads', d.plugin_uploads || 0, 'file(s)');
item('Installed plugins', (d.plugins || []).length);
box.replaceChildren(ul);
}).catch(function(err) {
if (isQuiet(err)) return;
box.textContent = 'Could not load preview: ' + message(err);
});
}
function historyTable(doc, entries) {
const table = el(doc, 'table', 'min-w-full divide-y divide-gray-200');
const head = el(doc, 'tr');
['Filename', 'Size', 'Created'].forEach(function(label) {
head.append(el(doc, 'th', 'text-left py-2', label));
});
head.append(el(doc, 'th'));
const thead = el(doc, 'thead');
thead.append(head);
const tbody = el(doc, 'tbody', 'divide-y divide-gray-100');
entries.forEach(function(entry) {
const name = String(entry.filename);
const row = el(doc, 'tr');
const actions = el(doc, 'td', 'py-2 text-right space-x-2');
const link = el(doc, 'a', 'text-blue-600 hover:underline', 'Download');
link.href = URLS.download + encodeURIComponent(name);
const remove = el(doc, 'button', 'text-red-600 hover:underline', 'Delete');
remove.type = 'button';
remove.dataset.action = 'delete';
remove.dataset.filename = name;
remove.setAttribute('aria-label', 'Delete ' + name);
actions.append(link, doc.createTextNode(' '), remove);
row.append(el(doc, 'td', 'py-2 font-mono text-xs', name),
el(doc, 'td', 'py-2', formatSize(entry.size)),
el(doc, 'td', 'py-2', String(entry.created_at)),
actions);
tbody.append(row);
});
table.append(thead, tbody);
return table;
}
/** The Backup history card. A newer load supersedes an older one. */
export function loadList(ctx) {
const box = ctx.state.els.history;
const doc = ctx.root.ownerDocument;
const seq = (ctx.state.listSeq || 0) + 1;
ctx.state.listSeq = seq;
box.textContent = 'Loading…';
return ctx.api.get(URLS.list, { signal: ctx.signal }).then(function(body) {
if (ctx.state.listSeq !== seq) return;
const entries = Array.isArray(body.data) ? body.data : [];
box.replaceChildren(entries.length ? historyTable(doc, entries)
: el(doc, 'p', null, 'No backups have been created yet.'));
}).catch(function(err) {
if (isQuiet(err) || ctx.state.listSeq !== seq) return;
box.textContent = 'Could not load backups: ' + message(err);
});
}
/** Create a backup, start its download, and refresh the history. */
export function exportZip(ctx) {
const button = ctx.state.els.exportButton;
if (button.disabled) return Promise.resolve(false);
setBusy(button, true, 'fa-download', 'Download backup', 'Creating…');
return ctx.api.post(URLS.exportZip, {}).then(function(body) {
ctx.notify('Backup created: ' + body.filename, 'success');
if (ctx.signal.aborted) return true;
ctx.root.ownerDocument.defaultView.location.assign(URLS.download + encodeURIComponent(body.filename));
return loadList(ctx).then(function() { return true; });
}).catch(function(err) {
if (!isQuiet(err)) ctx.notify('Export failed: ' + message(err), 'error');
return false;
}).finally(function() {
setBusy(button, false, 'fa-download', 'Download backup');
});
}
/** Ask, then delete one stored backup. */
export function deleteBackup(ctx, filename) {
const win = ctx.root.ownerDocument.defaultView;
if (!win.confirm('Delete ' + filename + '?')) return Promise.resolve(false);
return ctx.api.del(URLS.remove + encodeURIComponent(filename)).then(function() {
ctx.notify('Backup deleted', 'success');
if (!ctx.signal.aborted) loadList(ctx);
return true;
}).catch(function(err) {
if (!isQuiet(err)) ctx.notify('Delete failed: ' + message(err), 'error');
return false;
});
}
function hideRestore(ctx) {
ctx.state.inspected = null;
ctx.state.els.restorePreview.classList.add('hidden');
ctx.state.els.result.classList.add('hidden');
}
function renderManifest(ctx, manifest) {
const doc = ctx.root.ownerDocument;
const detected = manifest.detected_contents || [];
const plugins = manifest.plugins || [];
ctx.state.els.restorePreviewBody.replaceChildren(
labelled(doc, 'div', 'Created:', String(manifest.created_at || 'unknown')),
labelled(doc, 'div', 'Source host:', String(manifest.hostname || 'unknown')),
labelled(doc, 'div', 'LEDMatrix version:', String(manifest.ledmatrix_version || 'unknown')),
labelled(doc, 'div', 'Includes:', detected.length ? list(detected) : '(nothing detected)'),
labelled(doc, 'div', 'Plugins referenced:',
plugins.length ? list(plugins, function(p) { return String(p && p.plugin_id); }) : 'none'),
);
ctx.state.els.restorePreview.classList.remove('hidden');
}
/** Upload the chosen file for inspection and show what it holds. */
export function inspect(ctx) {
const input = ctx.state.els.fileInput;
const file = input.files && input.files[0];
if (!file) {
ctx.notify('Choose a backup file first', 'error');
return Promise.resolve(false);
}
const win = ctx.root.ownerDocument.defaultView;
const form = new win.FormData();
form.append('backup_file', file);
return ctx.api.request('POST', URLS.validate, { body: form, signal: ctx.signal }).then(function(body) {
// A different file picked while this one was being inspected.
if (!input.files || input.files[0] !== file) return false;
ctx.state.inspected = file;
renderManifest(ctx, body.data || {});
return true;
}).catch(function(err) {
if (!isQuiet(err)) ctx.notify('Invalid backup: ' + message(err), 'error');
return false;
});
}
/** The Cancel button: forget the inspected file. */
export function clear(ctx) {
hideRestore(ctx);
ctx.state.els.fileInput.value = '';
}
function renderResult(ctx, data, partial) {
const doc = ctx.root.ownerDocument;
const result = ctx.state.els.result;
result.className = (partial
? 'bg-yellow-50 border-yellow-200 text-yellow-800'
: 'bg-green-50 border-green-200 text-green-800') + ' border rounded-md p-4';
const lines = [
el(doc, 'h3', 'font-medium mb-2', partial ? 'Restore complete with warnings' : 'Restore complete'),
labelled(doc, 'div', 'Restored:', list(data.restored) || 'none'),
labelled(doc, 'div', 'Skipped:', list(data.skipped) || 'none'),
labelled(doc, 'div', 'Plugins installed:', list(data.plugins_installed) || 'none'),
labelled(doc, 'div', 'Plugins failed:', list(data.plugins_failed, function(p) {
return String(p.plugin_id) + ' (' + String(p.error) + ')';
}) || 'none'),
labelled(doc, 'div', 'Errors:', list(data.errors, String, '; ') || 'none'),
];
if ((data.restored || []).length || (data.plugins_installed || []).length) {
lines.push(el(doc, 'p', 'mt-2', 'Restart the display service to apply all changes.'));
}
result.replaceChildren(...lines);
}
/** Ask, then restore the inspected file with the chosen options. */
export function restore(ctx) {
const file = ctx.state.inspected;
if (!file) {
ctx.notify('Inspect the file before restoring', 'error');
return Promise.resolve(false);
}
const button = ctx.state.els.restoreButton;
if (button.disabled) return Promise.resolve(false); // one already running
const win = ctx.root.ownerDocument.defaultView;
if (!win.confirm('Restore from this backup? Current configuration will be overwritten.')) {
return Promise.resolve(false);
}
const options = {};
RESTORE_OPTIONS.forEach(function(pair) {
const box = ctx.root.querySelector('#' + pair[0]);
options[pair[1]] = !!(box && box.checked);
});
const form = new win.FormData();
form.append('backup_file', file);
form.append('options', JSON.stringify(options));
setBusy(button, true, 'fa-upload', 'Restore now', 'Restoring…');
return ctx.api.request('POST', URLS.restore, { body: form }).then(function(body) {
const data = body.data || {};
const partial = (data.plugins_failed || []).length > 0 || (data.errors || []).length > 0;
if (!ctx.signal.aborted) {
renderResult(ctx, data, partial);
ctx.state.els.result.classList.remove('hidden');
}
ctx.notify(partial ? 'Restore complete with warnings' : 'Restore complete',
partial ? 'warning' : 'success');
return true;
}).catch(function(err) {
if (isQuiet(err)) return false;
const errors = (err.body && err.body.data && err.body.data.errors) || [];
ctx.notify('Restore failed: ' + ((err.body && err.body.message) || errors.join('; ')
|| err.message || 'Restore had errors'), 'error');
return false;
}).finally(function() {
setBusy(button, false, 'fa-upload', 'Restore now');
});
}
const ACTIONS = {
'export': exportZip,
'refresh': loadList,
'inspect': inspect,
'restore': restore,
'cancel': clear,
};
export function init(root, ctx) {
const els = elements(root);
ctx.state.els = els;
ctx.state.inspected = null;
const on = { signal: ctx.signal };
root.addEventListener('click', function(event) {
const button = event.target.closest('button[data-action]');
if (!button || !root.contains(button)) return;
if (button.dataset.action === 'delete') {
deleteBackup(ctx, button.dataset.filename);
return;
}
const action = ACTIONS[button.dataset.action];
if (action) action(ctx);
}, on);
// A newly picked file has to be inspected again before it can be restored.
els.fileInput.addEventListener('change', function() { hideRestore(ctx); }, on);
active = ctx;
loadPreview(ctx);
loadList(ctx);
}
export function destroy(root, ctx) {
if (active === ctx) active = null;
}
// ── the old globals, kept as deprecated aliases (boot.js) ────────────────────
function forward(action) {
return function() { return active ? action(active) : Promise.resolve(false); };
}
/** window.exportBackup() */
export const exportBackup = forward(exportZip);
/** window.loadBackupList() */
export const loadBackupList = forward(loadList);
/** window.validateRestoreFile() */
export const validateRestoreFile = forward(inspect);
/** window.clearRestore() */
export const clearRestore = forward(clear);
/** window.runRestore() */
export const runRestore = forward(restore);
@@ -0,0 +1,50 @@
/*
* pages/durations.js -- the Rotation & Durations tab
* (templates/v3/partials/durations.html).
*
* The form itself is plain htmx (hx-post to /api/v3/config/main), so the only
* code here starts the shared drag-and-drop rotation-order list
* (static/v3/js/widgets/plugin-order-list.js, the same widget the Vegas
* section of the Display tab uses). Its plugin-list request carries
* ctx.signal, so a swap cancels it.
*
* The widget is a classic deferred script and normally loads before this
* module runs; the short retry covers a page that arrives first anyway.
*/
const CONTAINER_ID = 'rotation_plugin_order';
const ORDER_INPUT_ID = 'rotation_plugin_order_value';
const RETRY_MS = 100;
const MAX_TRIES = 50;
export function init(root, ctx) {
const container = root.querySelector('#' + CONTAINER_ID);
if (!container) return;
const win = root.ownerDocument.defaultView;
let tries = 0;
function start() {
ctx.state.timer = null;
if (ctx.signal.aborted) return;
const widget = win.PluginOrderList;
if (!widget) {
if (++tries < MAX_TRIES) {
ctx.state.timer = win.setTimeout(start, RETRY_MS);
} else {
// Say so rather than showing "Loading..." for ever.
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
widget.init({ containerId: CONTAINER_ID, orderInputId: ORDER_INPUT_ID, signal: ctx.signal });
}
start();
}
export function destroy(root, ctx) {
if (ctx.state.timer) {
root.ownerDocument.defaultView.clearTimeout(ctx.state.timer);
ctx.state.timer = null;
}
}
@@ -0,0 +1,283 @@
/*
* pages/operation-history.js -- the Operation History tab
* (templates/v3/partials/operation_history.html).
*
* A read-only audit list: the page loads up to 1,000 records once, then
* filters, searches and pages through them in the browser. Rows are built
* with textContent, never markup strings, so a plugin id or error message
* from the log is always shown as text.
*
* Every listener and read request carries ctx.signal. Clearing the history is
* a write, so it is left to finish if the page is swapped away mid-request;
* its result is then only reported, not drawn.
*/
const HISTORY_URL = '/api/v3/plugins/operation/history';
const LIST_URL = HISTORY_URL + '?limit=1000';
const PLUGINS_URL = '/api/v3/plugins/installed';
const PAGE_SIZE = 50;
const SEARCH_DELAY_MS = 300;
const STATUS_CLASSES = {
success: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
const TYPE_CLASSES = {
install: 'bg-blue-100 text-blue-800',
update: 'bg-purple-100 text-purple-800',
uninstall: 'bg-red-100 text-red-800',
enable: 'bg-green-100 text-green-800',
disable: 'bg-yellow-100 text-yellow-800',
configure: 'bg-teal-100 text-teal-800',
};
const NEUTRAL_CLASS = 'bg-gray-100 text-gray-800';
function isQuiet(error) {
// A cancelled request (the page was swapped away) or the login redirect
// (the browser is already leaving): nothing to tell the user.
return !!error && (error.name === 'AbortError' || error.loginRequired);
}
/**
* The server says "completed" or "success" for a success and "error" or
* "failed" for a failure; the status filter's values are success/failed.
*/
export function normalizeStatus(status) {
const s = String(status || '').toLowerCase();
if (s === 'completed' || s === 'success') return 'success';
if (s === 'error' || s === 'failed') return 'failed';
return s;
}
/** The short "v1.2 | commit: abc1234" summary of a record's details. */
export function detailsSummary(details) {
if (!details || typeof details !== 'object') return '';
const parts = [];
if (details.version) parts.push('v' + details.version);
if (details.branch) parts.push('branch: ' + details.branch);
if (details.commit) parts.push('commit: ' + String(details.commit).substring(0, 7));
if (details.previous_commit && details.commit && details.previous_commit !== details.commit) {
parts.push('from: ' + String(details.previous_commit).substring(0, 7));
}
if (details.preserve_config !== undefined) {
parts.push(details.preserve_config ? 'config preserved' : 'config removed');
}
return parts.join(' | ');
}
/** The records that pass the current filters. */
export function filterRecords(records, filters) {
const search = (filters.search || '').toLowerCase();
return records.filter(function(record) {
if (filters.plugin && record.plugin_id !== filters.plugin) return false;
if (filters.type && record.operation_type !== filters.type) return false;
if (filters.status && normalizeStatus(record.status) !== filters.status) return false;
if (search) {
const searchable = [
record.operation_type, record.plugin_id, record.status, record.user, record.error,
JSON.stringify(record.details || {}),
].join(' ').toLowerCase();
if (!searchable.includes(search)) return false;
}
return true;
});
}
function elements(root) {
const $ = function(id) { return root.querySelector('#' + id); };
return {
plugin: $('history-plugin-filter'),
type: $('history-type-filter'),
status: $('history-status-filter'),
search: $('history-search'),
refresh: $('refresh-history-btn'),
clear: $('clear-history-btn'),
tbody: $('history-table-body'),
start: $('history-start'),
end: $('history-end'),
total: $('history-total'),
prev: $('history-prev-btn'),
next: $('history-next-btn'),
};
}
function el(doc, tag, className, text) {
const node = doc.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function messageRow(doc, text) {
const row = el(doc, 'tr');
const cell = el(doc, 'td', 'px-6 py-4 text-center text-gray-500', text);
cell.colSpan = 6;
row.append(cell);
return row;
}
function badge(doc, className, text) {
return el(doc, 'span', 'px-2 py-1 text-xs font-medium rounded ' + className, text);
}
function historyRow(doc, record) {
const row = el(doc, 'tr', 'hover:bg-gray-50');
const when = new Date(record.timestamp || record.created_at || Date.now());
const status = normalizeStatus(record.status);
const type = record.operation_type || 'unknown';
const typeCell = el(doc, 'td', 'px-6 py-4 whitespace-nowrap text-sm');
typeCell.append(badge(doc, TYPE_CLASSES[String(type).toLowerCase()] || NEUTRAL_CLASS, String(type)));
const statusCell = el(doc, 'td', 'px-6 py-4 whitespace-nowrap text-sm');
statusCell.append(badge(doc, STATUS_CLASSES[status] || NEUTRAL_CLASS, status));
const detailsCell = el(doc, 'td', 'px-6 py-4 text-sm text-gray-500');
const summary = detailsSummary(record.details);
detailsCell.append(summary ? el(doc, 'span', 'text-xs text-gray-500', summary) : doc.createTextNode('-'));
if (record.error) detailsCell.append(el(doc, 'div', 'text-red-600 text-xs mt-1', String(record.error)));
row.append(
el(doc, 'td', 'px-6 py-4 whitespace-nowrap text-sm text-gray-900', when.toLocaleString()),
typeCell,
el(doc, 'td', 'px-6 py-4 whitespace-nowrap text-sm text-gray-900', String(record.plugin_id || '-')),
statusCell,
el(doc, 'td', 'px-6 py-4 whitespace-nowrap text-sm text-gray-500', String(record.user || '-')),
detailsCell,
);
return row;
}
function render(ctx) {
const s = ctx.state;
const { tbody, start: startEl, end: endEl, total, prev, next } = s.els;
const doc = ctx.root.ownerDocument;
const pages = Math.max(1, Math.ceil(s.filtered.length / PAGE_SIZE));
if (s.page > pages) s.page = pages;
const start = (s.page - 1) * PAGE_SIZE;
const end = Math.min(start + PAGE_SIZE, s.filtered.length);
const rows = s.filtered.slice(start, end);
tbody.replaceChildren(...(rows.length
? rows.map(function(record) { return historyRow(doc, record); })
: [messageRow(doc, 'No operations found')]));
// Kept in step even when nothing matches, so "Showing 1 to 50" does not
// linger under an empty table.
startEl.textContent = String(s.filtered.length ? start + 1 : 0);
endEl.textContent = String(end);
total.textContent = String(s.filtered.length);
prev.disabled = s.page <= 1;
next.disabled = end >= s.filtered.length;
}
/** Re-apply the filters to the loaded records and draw the first page. */
export function applyFilters(ctx, keepPage) {
const { plugin, type, status, search } = ctx.state.els;
ctx.state.filtered = filterRecords(ctx.state.records, {
plugin: plugin.value, type: type.value, status: status.value, search: search.value,
});
if (!keepPage) ctx.state.page = 1;
render(ctx);
}
/** Fetch the history and draw it. A newer load supersedes an older one. */
export function load(ctx) {
const seq = (ctx.state.seq || 0) + 1;
ctx.state.seq = seq;
return ctx.api.get(LIST_URL, { signal: ctx.signal }).then(function(body) {
if (ctx.state.seq !== seq) return;
ctx.state.records = Array.isArray(body.data) ? body.data : [];
applyFilters(ctx, true);
}).catch(function(err) {
if (isQuiet(err) || ctx.state.seq !== seq) return;
ctx.notify(err.network || !err.status
? 'Error loading operation history'
: 'Failed to load operation history', 'error');
});
}
function installedPlugins(ctx) {
// PluginAPI (js/plugins/api_client.js) caches the installed list it
// shares with the Plugin Manager, so prefer it: a reloaded tab then costs
// no extra request. Without it, ask the API directly.
const pluginApi = ctx.root.ownerDocument.defaultView.PluginAPI;
if (pluginApi && typeof pluginApi.getInstalledPlugins === 'function') {
return Promise.resolve(pluginApi.getInstalledPlugins());
}
return ctx.api.get(PLUGINS_URL, { signal: ctx.signal }).then(function(body) {
return (body.data && Array.isArray(body.data.plugins)) ? body.data.plugins : [];
});
}
/** Fill the plugin filter with the installed plugins' ids. */
export function loadPluginFilter(ctx) {
return installedPlugins(ctx).then(function(plugins) {
if (ctx.signal.aborted || !Array.isArray(plugins)) return;
const doc = ctx.root.ownerDocument;
const select = ctx.state.els.plugin;
plugins.map(function(p) { return p && p.id; }).filter(Boolean).forEach(function(id) {
const option = doc.createElement('option');
option.value = String(id);
option.textContent = String(id);
select.append(option);
});
}).catch(function(err) {
if (!isQuiet(err)) ctx.root.ownerDocument.defaultView.console.error('Error loading plugins for filter:', err);
});
}
/** Ask, then clear the whole history. */
export function clearHistory(ctx) {
const win = ctx.root.ownerDocument.defaultView;
if (!win.confirm('Are you sure you want to clear the operation history? This cannot be undone.')) {
return Promise.resolve(false);
}
return ctx.api.del(HISTORY_URL).then(function() {
ctx.state.seq = (ctx.state.seq || 0) + 1; // an older load must not redraw the old list
if (ctx.signal.aborted) return true;
ctx.state.records = [];
applyFilters(ctx);
return true;
}).catch(function(err) {
if (isQuiet(err)) return false;
ctx.notify(err.network || !err.status ? 'Error clearing history'
: (err.message || 'Failed to clear history'), 'error');
return false;
});
}
export function init(root, ctx) {
const els = elements(root);
const win = root.ownerDocument.defaultView;
const on = { signal: ctx.signal };
Object.assign(ctx.state, { els: els, records: [], filtered: [], page: 1, searchTimer: null });
els.refresh.addEventListener('click', function() { load(ctx); }, on);
els.clear.addEventListener('click', function() { clearHistory(ctx); }, on);
els.prev.addEventListener('click', function() {
if (ctx.state.page > 1) { ctx.state.page--; render(ctx); }
}, on);
els.next.addEventListener('click', function() {
if (ctx.state.page * PAGE_SIZE < ctx.state.filtered.length) { ctx.state.page++; render(ctx); }
}, on);
[els.plugin, els.type, els.status].forEach(function(select) {
select.addEventListener('change', function() { applyFilters(ctx); }, on);
});
els.search.addEventListener('input', function() {
win.clearTimeout(ctx.state.searchTimer);
ctx.state.searchTimer = win.setTimeout(function() {
ctx.state.searchTimer = null;
applyFilters(ctx);
}, SEARCH_DELAY_MS);
}, on);
loadPluginFilter(ctx);
load(ctx);
}
export function destroy(root, ctx) {
if (ctx.state.searchTimer) {
root.ownerDocument.defaultView.clearTimeout(ctx.state.searchTimer);
ctx.state.searchTimer = null;
}
}
@@ -0,0 +1,191 @@
/*
* pages/raw-json.js -- the Config Editor tab (templates/v3/partials/raw_json.html):
* plain-text editors for config.json and config_secrets.json.
*
* The buttons carry data-action ("format", "validate", "save") and
* data-editor ("main", "secrets"); one delegated listener on the page root
* handles all six. Validation messages are built with textContent, so a
* parser message that quotes the user's text stays text.
*
* Saves are writes: they are not cancelled when the page is swapped away,
* so the server always finishes and the result is still reported.
*
* The old globals (formatJson, manualValidateJson, validateJSON,
* saveMainConfig, saveSecretsConfig) are deprecated aliases made in
* core/boot.js; they forward to the exports at the bottom of this file.
*/
const EDITORS = {
main: {
editor: 'main-config-editor', validation: 'main-config-validation',
url: '/api/v3/config/raw/main', file: 'config.json',
},
secrets: {
editor: 'secrets-config-editor', validation: 'secrets-config-validation',
url: '/api/v3/config/raw/secrets', file: 'config_secrets.json',
},
};
// The mounted page, for the deprecated aliases (boot.js).
let active = null;
function el(doc, tag, className, text) {
const node = doc.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function parseError(text) {
try {
JSON.parse(text);
return null;
} catch (error) {
return error;
}
}
function parts(ctx, kind) {
const spec = EDITORS[kind];
if (!spec) return null;
const textarea = ctx.root.querySelector('#' + spec.editor);
const validation = ctx.root.querySelector('#' + spec.validation);
return textarea && validation ? { spec: spec, textarea: textarea, validation: validation } : null;
}
/** The short "Valid JSON" / "Invalid JSON: ..." line under an editor. True when valid. */
export function validate(ctx, kind) {
const p = parts(ctx, kind);
if (!p) return true;
const doc = ctx.root.ownerDocument;
const error = parseError(p.textarea.value);
const line = error
? el(doc, 'span', 'text-red-600')
: el(doc, 'span', 'text-green-600');
line.append(el(doc, 'i', (error ? 'fas fa-times-circle' : 'fas fa-check-circle') + ' mr-1'),
doc.createTextNode(error ? 'Invalid JSON: ' + error.message : 'Valid JSON'));
p.validation.replaceChildren(line);
return !error;
}
/** The Validate button: a detailed box, and a notification. */
export function validateDetailed(ctx, kind) {
const p = parts(ctx, kind);
if (!p) return false;
const doc = ctx.root.ownerDocument;
const error = parseError(p.textarea.value);
const box = el(doc, 'div', error
? 'p-3 bg-red-50 border border-red-200 rounded-md'
: 'p-3 bg-green-50 border border-green-200 rounded-md');
const row = el(doc, 'div', 'flex items-start');
const text = el(doc, 'div');
const detail = el(doc, 'div', error ? 'text-sm text-red-700 mt-1' : 'text-sm text-green-700 mt-1');
if (error) {
row.append(el(doc, 'i', 'fas fa-times-circle text-red-600 text-xl mr-3 mt-1'));
text.append(el(doc, 'div', 'font-semibold text-red-800', '✗ Invalid JSON syntax'));
detail.append(el(doc, 'strong', null, 'Error:'), doc.createTextNode(' ' + error.message));
} else {
row.append(el(doc, 'i', 'fas fa-check-circle text-green-600 text-xl mr-3 mt-1'));
text.append(el(doc, 'div', 'font-semibold text-green-800', '✓ JSON is valid!'));
['✓ Valid JSON syntax', '✓ Proper structure', '✓ No syntax errors detected'].forEach(function(line, i) {
if (i) detail.append(el(doc, 'br'));
detail.append(doc.createTextNode(line));
});
}
text.append(detail);
row.append(text);
box.append(row);
p.validation.replaceChildren(box);
ctx.notify(error ? 'JSON validation failed: ' + error.message : 'JSON validation successful!',
error ? 'error' : 'success');
return !error;
}
/** The Format button: re-indent valid JSON by four spaces. */
export function format(ctx, kind) {
const p = parts(ctx, kind);
if (!p) return false;
try {
p.textarea.value = JSON.stringify(JSON.parse(p.textarea.value), null, 4);
validate(ctx, kind);
ctx.notify('JSON formatted successfully!', 'success');
return true;
} catch (error) {
validate(ctx, kind);
ctx.notify('Cannot format invalid JSON: ' + error.message, 'error');
return false;
}
}
/** The Save button: validate, then post the parsed file. */
export function save(ctx, kind) {
const p = parts(ctx, kind);
if (!p) return Promise.resolve(false);
if (!validate(ctx, kind)) {
ctx.notify('Invalid JSON! Please fix errors before saving.', 'error');
return Promise.resolve(false);
}
const config = JSON.parse(p.textarea.value);
return ctx.api.post(p.spec.url, config).then(function() {
ctx.notify(p.spec.file + ' saved successfully!', 'success');
return true;
}).catch(function(err) {
if (err && err.loginRequired) return false;
ctx.notify('Error saving ' + p.spec.file + ': ' + ((err && err.message) || 'An error occurred'), 'error');
return false;
});
}
const ACTIONS = { format: format, validate: validateDetailed, save: save };
export function init(root, ctx) {
const on = { signal: ctx.signal };
root.addEventListener('click', function(event) {
const button = event.target.closest('button[data-action][data-editor]');
if (!button || !root.contains(button)) return;
const action = ACTIONS[button.dataset.action];
if (action) action(ctx, button.dataset.editor);
}, on);
Object.keys(EDITORS).forEach(function(kind) {
const p = parts(ctx, kind);
if (!p) return;
p.textarea.addEventListener('input', function() { validate(ctx, kind); }, on);
validate(ctx, kind);
});
active = ctx;
}
export function destroy(root, ctx) {
if (active === ctx) active = null;
}
// ── the old globals, kept as deprecated aliases (boot.js) ────────────────────
// They took element ids; map those back to an editor.
function kindOf(editorId) {
return Object.keys(EDITORS).find(function(kind) { return EDITORS[kind].editor === editorId; });
}
/** window.formatJson(editorId, validationDivId) */
export function formatJson(editorId) {
return active ? format(active, kindOf(editorId)) : false;
}
/** window.manualValidateJson(editorId, validationDivId) */
export function manualValidateJson(editorId) {
return active ? validateDetailed(active, kindOf(editorId)) : false;
}
/** window.validateJSON(editorId, validationDivId): true when valid. */
export function validateJSON(editorId) {
return active ? validate(active, kindOf(editorId)) : true;
}
/** window.saveMainConfig() */
export function saveMainConfig() {
return active ? save(active, 'main') : Promise.resolve(false);
}
/** window.saveSecretsConfig() */
export function saveSecretsConfig() {
return active ? save(active, 'secrets') : Promise.resolve(false);
}
@@ -12,7 +12,9 @@
* orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids
* excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an
* // include-checkbox per row; unchecked ids collect here (JSON array)
* showVegasModeBadge: true // optional: Scroll/Pause/Excluded badge
* showVegasModeBadge: true, // optional: Scroll/Pause/Excluded badge
* signal: ctx.signal // optional: AbortSignal that cancels
* // the plugin-list request (a page module's ctx.signal)
* });
*
* The container re-renders from /api/v3/plugins/installed each init; the
@@ -97,7 +99,7 @@
});
}
fetch('/api/v3/plugins/installed')
fetch('/api/v3/plugins/installed', { signal: options.signal })
.then(response => response.json())
.then(data => {
const allPlugins = (data.data && data.data.plugins) || data.plugins || [];
@@ -216,6 +218,8 @@
syncInputs();
})
.catch(error => {
// The page was swapped away (options.signal): nothing to draw.
if (error && error.name === 'AbortError') return;
console.error('Error fetching plugins:', error);
const err = document.createElement('p');
err.className = 'text-sm text-red-500';
@@ -1,5 +1,9 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="space-y-6" id="backup-restore-root">
{# No inline script: static/v3/js/pages/backup-restore.js runs this page. Its
buttons name an action (data-action) for one delegated listener. The page
registry (static/v3/js/core/registry.js) starts it when this root appears
and stops it when the partial is swapped away. #}
<div class="space-y-6" id="backup-restore-root" data-page="backup-restore">
<!-- Security warning -->
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
@@ -28,7 +32,7 @@
Download a single ZIP with all of your settings so you can restore it later.
</p>
</div>
<button onclick="exportBackup()" id="export-backup-btn"
<button type="button" data-action="export" 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
@@ -58,7 +62,7 @@
</div>
<div>
<button onclick="validateRestoreFile()" id="validate-restore-btn"
<button type="button" data-action="inspect" 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
@@ -80,12 +84,12 @@
</div>
<div class="mt-4 flex gap-2">
<button onclick="runRestore()" id="run-restore-btn"
<button type="button" data-action="restore" 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()"
<button type="button" data-action="cancel"
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>
@@ -104,7 +108,7 @@
<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()"
<button type="button" data-action="refresh"
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
@@ -115,237 +119,3 @@
</div>
</div>
<script>
(function () {
let inspectedFile = null;
function notify(message, kind) {
showNotification(message, kind || 'info');
}
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 window.LEDEscape.html(value); }
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>
@@ -1,5 +1,8 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6">
{# No inline script: static/v3/js/pages/durations.js starts the rotation-order
list. The page registry (static/v3/js/core/registry.js) starts it when this
root appears and stops it when the partial is swapped away. #}
<div class="bg-white rounded-lg shadow p-6" data-page="durations">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Rotation &amp; Durations</h2>
<p class="mt-1 text-sm text-gray-600">Set the order plugins rotate on the display and how long each screen is shown. Durations are in seconds.</p>
@@ -77,34 +80,3 @@
</div>
</form>
</div>
<script>
(function () {
// Shared drag-and-drop plugin list (static/v3/js/widgets/plugin-order-list.js,
// same module the Vegas Scroll section uses).
function initRotationOrderList(attempt) {
const container = document.getElementById('rotation_plugin_order');
if (!container) return;
if (!window.PluginOrderList) {
// Widget script is deferred; retry briefly, then surface a real
// error instead of showing "Loading…" forever.
if ((attempt || 0) < 50) {
setTimeout(function() { initRotationOrderList((attempt || 0) + 1); }, 100);
} else {
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
window.PluginOrderList.init({
containerId: 'rotation_plugin_order',
orderInputId: 'rotation_plugin_order_value'
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initRotationOrderList);
} else {
initRotationOrderList();
}
}());
</script>
@@ -1,4 +1,7 @@
<div class="bg-white rounded-lg shadow p-6">
{# No inline script: static/v3/js/pages/operation-history.js runs this page.
The page registry (static/v3/js/core/registry.js) starts it when this root
appears and stops it when the partial is swapped away. #}
<div class="bg-white rounded-lg shadow p-6" data-page="operation-history">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Operation History</h2>
<p class="mt-1 text-sm text-gray-600">View history of plugin operations and configuration changes for debugging and auditing.</p>
@@ -39,12 +42,12 @@
<div class="flex items-center space-x-2">
<!-- Refresh -->
<button id="refresh-history-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-sm">
<button type="button" id="refresh-history-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-sm">
<i class="fas fa-sync-alt mr-1"></i>Refresh
</button>
<!-- Clear History -->
<button id="clear-history-btn" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm">
<button type="button" id="clear-history-btn" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm">
<i class="fas fa-trash mr-1"></i>Clear
</button>
</div>
@@ -81,307 +84,12 @@
Showing <span id="history-start">0</span> to <span id="history-end">0</span> of <span id="history-total">0</span> operations
</div>
<div class="flex space-x-2">
<button id="history-prev-btn" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm disabled:opacity-50" disabled>
<button type="button" id="history-prev-btn" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm disabled:opacity-50" disabled>
<i class="fas fa-chevron-left mr-1"></i>Previous
</button>
<button id="history-next-btn" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm disabled:opacity-50" disabled>
<button type="button" id="history-next-btn" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm disabled:opacity-50" disabled>
Next<i class="fas fa-chevron-right ml-1"></i>
</button>
</div>
</div>
</div>
<script>
(function() {
let currentPage = 1;
const pageSize = 50;
let allHistory = [];
let filteredHistory = [];
// Normalize status values so both server-side conventions match filter values.
// The server may return "completed" or "success" for successful operations,
// and "error" or "failed" for failures.
function normalizeStatus(status) {
const s = (status || '').toLowerCase();
if (s === 'completed' || s === 'success') return 'success';
if (s === 'error' || s === 'failed') return 'failed';
return s;
}
// Load operation history
async function loadHistory() {
try {
const response = await fetch('/api/v3/plugins/operation/history?limit=1000');
const data = await response.json();
if (data.status === 'success') {
allHistory = data.data || [];
applyFilters();
} else {
console.error('Failed to load history:', data.message);
showError('Failed to load operation history');
}
} catch (error) {
console.error('Error loading history:', error);
showError('Error loading operation history');
}
}
// Apply filters
function applyFilters() {
const pluginFilter = document.getElementById('history-plugin-filter')?.value || '';
const typeFilter = document.getElementById('history-type-filter')?.value || '';
const statusFilter = document.getElementById('history-status-filter')?.value || '';
const searchTerm = (document.getElementById('history-search')?.value || '').toLowerCase();
filteredHistory = allHistory.filter(record => {
if (pluginFilter && record.plugin_id !== pluginFilter) return false;
if (typeFilter && record.operation_type !== typeFilter) return false;
if (statusFilter && normalizeStatus(record.status) !== statusFilter) return false;
if (searchTerm) {
const searchable = [
record.operation_type,
record.plugin_id,
record.status,
record.user,
record.error,
JSON.stringify(record.details || {})
].join(' ').toLowerCase();
if (!searchable.includes(searchTerm)) return false;
}
return true;
});
renderHistory();
}
// Render history table
function renderHistory() {
const tbody = document.getElementById('history-table-body');
if (!tbody) return;
const start = (currentPage - 1) * pageSize;
const end = Math.min(start + pageSize, filteredHistory.length);
const pageData = filteredHistory.slice(start, end);
if (pageData.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="px-6 py-4 text-center text-gray-500">
No operations found
</td>
</tr>
`;
return;
}
tbody.innerHTML = pageData.map(record => {
const timestamp = new Date(record.timestamp || record.created_at || Date.now());
const timeStr = timestamp.toLocaleString();
const normalized = normalizeStatus(record.status);
const statusClass = getStatusClass(normalized);
const operationType = record.operation_type || 'unknown';
const pluginId = record.plugin_id || '-';
const user = record.user || '-';
const error = record.error ? `<div class="text-red-600 text-xs mt-1">${escapeHtml(record.error)}</div>` : '';
// Build a concise details summary
let detailsSummary = '';
const d = record.details;
if (d) {
const parts = [];
if (d.version) parts.push(`v${d.version}`);
if (d.branch) parts.push(`branch: ${d.branch}`);
if (d.commit) parts.push(`commit: ${String(d.commit).substring(0, 7)}`);
if (d.previous_commit && d.commit && d.previous_commit !== d.commit) {
parts.push(`from: ${String(d.previous_commit).substring(0, 7)}`);
}
if (d.preserve_config !== undefined) parts.push(d.preserve_config ? 'config preserved' : 'config removed');
detailsSummary = parts.length > 0 ? `<span class="text-xs text-gray-500">${escapeHtml(parts.join(' | '))}</span>` : '';
}
return `
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">${escapeHtml(timeStr)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs font-medium rounded ${getOperationTypeClass(operationType)}">
${escapeHtml(operationType)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">${escapeHtml(pluginId)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs font-medium rounded ${statusClass}">
${escapeHtml(normalized)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${escapeHtml(user)}</td>
<td class="px-6 py-4 text-sm text-gray-500">
${detailsSummary || '-'}
${error}
</td>
</tr>
`;
}).join('');
// Update pagination
document.getElementById('history-start').textContent = filteredHistory.length > 0 ? start + 1 : 0;
document.getElementById('history-end').textContent = end;
document.getElementById('history-total').textContent = filteredHistory.length;
// Update pagination buttons
const prevBtn = document.getElementById('history-prev-btn');
const nextBtn = document.getElementById('history-next-btn');
if (prevBtn) prevBtn.disabled = currentPage === 1;
if (nextBtn) nextBtn.disabled = end >= filteredHistory.length;
}
// Helper functions
function getStatusClass(status) {
if (status === 'success') return 'bg-green-100 text-green-800';
if (status === 'failed') return 'bg-red-100 text-red-800';
return 'bg-gray-100 text-gray-800';
}
function getOperationTypeClass(type) {
const typeLower = (type || '').toLowerCase();
if (typeLower === 'install') {
return 'bg-blue-100 text-blue-800';
} else if (typeLower === 'update') {
return 'bg-purple-100 text-purple-800';
} else if (typeLower === 'uninstall') {
return 'bg-red-100 text-red-800';
} else if (typeLower === 'enable') {
return 'bg-green-100 text-green-800';
} else if (typeLower === 'disable') {
return 'bg-yellow-100 text-yellow-800';
} else if (typeLower === 'configure') {
return 'bg-teal-100 text-teal-800';
}
return 'bg-gray-100 text-gray-800';
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function showError(message) {
showNotification(message, 'error');
}
// Populate plugin filter
async function populatePluginFilter() {
try {
// Use PluginAPI if available, otherwise fall back to direct fetch
let data;
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
const plugins = await window.PluginAPI.getInstalledPlugins();
data = { status: 'success', data: { plugins: plugins } };
} else {
const response = await fetch('/api/v3/plugins/installed');
data = await response.json();
}
if (data.status === 'success' && data.data) {
const select = document.getElementById('history-plugin-filter');
if (select) {
const plugins = (data.data.plugins || []).map(p => p.id).filter(Boolean);
plugins.forEach(pluginId => {
const option = document.createElement('option');
option.value = pluginId;
option.textContent = pluginId;
select.appendChild(option);
});
}
}
} catch (error) {
console.error('Error loading plugins for filter:', error);
}
}
// Event listeners
function setupEventListeners() {
const refreshBtn = document.getElementById('refresh-history-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
loadHistory();
});
}
const clearBtn = document.getElementById('clear-history-btn');
if (clearBtn) {
clearBtn.addEventListener('click', async () => {
if (confirm('Are you sure you want to clear the operation history? This cannot be undone.')) {
try {
const response = await fetch('/api/v3/plugins/operation/history', { method: 'DELETE' });
const data = await response.json();
if (data.status === 'success') {
allHistory = [];
applyFilters();
} else {
showError(data.message || 'Failed to clear history');
}
} catch (error) {
showError('Error clearing history');
}
}
});
}
const prevBtn = document.getElementById('history-prev-btn');
if (prevBtn) {
prevBtn.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
renderHistory();
}
});
}
const nextBtn = document.getElementById('history-next-btn');
if (nextBtn) {
nextBtn.addEventListener('click', () => {
const start = (currentPage - 1) * pageSize;
const end = Math.min(start + pageSize, filteredHistory.length);
if (end < filteredHistory.length) {
currentPage++;
renderHistory();
}
});
}
// Filter change listeners
['history-plugin-filter', 'history-type-filter', 'history-status-filter'].forEach(id => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('change', () => {
currentPage = 1;
applyFilters();
});
}
});
const searchInput = document.getElementById('history-search');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentPage = 1;
applyFilters();
}, 300);
});
}
}
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setupEventListeners();
populatePluginFilter();
loadHistory();
});
} else {
setupEventListeners();
populatePluginFilter();
loadHistory();
}
})();
</script>
+11 -222
View File
@@ -1,4 +1,8 @@
<div class="space-y-6">
{# No inline script: static/v3/js/pages/raw-json.js runs this page. Its buttons
name an action and an editor (data-action, data-editor) for one delegated
listener. The page registry (static/v3/js/core/registry.js) starts it when
this root appears and stops it when the partial is swapped away. #}
<div class="space-y-6" data-page="raw-json">
<!-- Config.json Editor -->
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
@@ -8,17 +12,17 @@
<p class="mt-1 text-sm text-gray-600">{{ main_config_path }}</p>
</div>
<div class="flex gap-2">
<button onclick="formatJson('main-config-editor', 'main-config-validation')"
<button type="button" data-action="format" data-editor="main"
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-align-left mr-2"></i>
Format JSON
</button>
<button onclick="manualValidateJson('main-config-editor', 'main-config-validation')"
<button type="button" data-action="validate" data-editor="main"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-check-circle mr-2"></i>
Validate JSON
</button>
<button onclick="saveMainConfig()"
<button type="button" data-action="save" data-editor="main"
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-save mr-2"></i>
Save
@@ -59,17 +63,17 @@
<p class="mt-1 text-sm text-gray-600">{{ secrets_config_path }}</p>
</div>
<div class="flex gap-2">
<button onclick="formatJson('secrets-config-editor', 'secrets-config-validation')"
<button type="button" data-action="format" data-editor="secrets"
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-align-left mr-2"></i>
Format JSON
</button>
<button onclick="manualValidateJson('secrets-config-editor', 'secrets-config-validation')"
<button type="button" data-action="validate" data-editor="secrets"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-check-circle mr-2"></i>
Validate JSON
</button>
<button onclick="saveSecretsConfig()"
<button type="button" data-action="save" data-editor="secrets"
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-save mr-2"></i>
Save
@@ -101,218 +105,3 @@
</div>
</div>
</div>
<script>
// Format JSON with proper indentation
function formatJson(editorId, validationDivId) {
const textarea = document.getElementById(editorId);
const jsonText = textarea.value;
try {
const parsed = JSON.parse(jsonText);
const formatted = JSON.stringify(parsed, null, 4);
textarea.value = formatted;
// Auto-validate after formatting
validateJSON(editorId, validationDivId);
showNotification('JSON formatted successfully!', 'success');
} catch (error) {
showNotification('Cannot format invalid JSON: ' + error.message, 'error');
validateJSON(editorId, validationDivId);
}
}
// Manual validation with detailed feedback
function manualValidateJson(editorId, validationDivId) {
const textarea = document.getElementById(editorId);
const validation = document.getElementById(validationDivId);
const jsonText = textarea.value;
if (!textarea || !validation) return;
try {
const parsed = JSON.parse(jsonText);
validation.innerHTML = `
<div class="p-3 bg-green-50 border border-green-200 rounded-md">
<div class="flex items-start">
<i class="fas fa-check-circle text-green-600 text-xl mr-3 mt-1"></i>
<div>
<div class="font-semibold text-green-800">✓ JSON is valid!</div>
<div class="text-sm text-green-700 mt-1">
✓ Valid JSON syntax<br>
✓ Proper structure<br>
✓ No syntax errors detected
</div>
</div>
</div>
</div>
`;
showNotification('JSON validation successful!', 'success');
} catch (e) {
validation.innerHTML = `
<div class="p-3 bg-red-50 border border-red-200 rounded-md">
<div class="flex items-start">
<i class="fas fa-times-circle text-red-600 text-xl mr-3 mt-1"></i>
<div>
<div class="font-semibold text-red-800">✗ Invalid JSON syntax</div>
<div class="text-sm text-red-700 mt-1">
<strong>Error:</strong> ${window.LEDEscape.html(e.message)}
</div>
</div>
</div>
</div>
`;
showNotification('JSON validation failed: ' + e.message, 'error');
}
}
// Auto-validate JSON as user types (simple version)
function validateJSON(editor, validationDiv) {
const textarea = document.getElementById(editor);
const validation = document.getElementById(validationDiv);
if (!textarea || !validation) return true;
try {
JSON.parse(textarea.value);
validation.innerHTML = '<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Valid JSON</span>';
return true;
} catch (e) {
validation.innerHTML = '<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Invalid JSON: ' + e.message + '</span>';
return false;
}
}
// Auto-validate on input
document.getElementById('main-config-editor')?.addEventListener('input', function() {
validateJSON('main-config-editor', 'main-config-validation');
});
document.getElementById('secrets-config-editor')?.addEventListener('input', function() {
validateJSON('secrets-config-editor', 'secrets-config-validation');
});
// Initial validation
setTimeout(() => {
validateJSON('main-config-editor', 'main-config-validation');
validateJSON('secrets-config-editor', 'secrets-config-validation');
}, 100);
function saveMainConfig() {
const textarea = document.getElementById('main-config-editor');
// Validate JSON first
if (!validateJSON('main-config-editor', 'main-config-validation')) {
showNotification('Invalid JSON! Please fix errors before saving.', 'error');
return;
}
try {
const config = JSON.parse(textarea.value);
// Save via API
fetch('/api/v3/config/raw/main', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
})
.then(async response => {
// Store status and statusText before parsing
const status = response.status;
const statusText = response.statusText;
// Try to parse JSON response
let data;
try {
data = await response.json();
} catch (parseError) {
// If JSON parsing fails, throw generic HTTP error
throw new Error(`HTTP ${status}: ${statusText}`);
}
// Handle non-OK responses
if (!response.ok) {
// Extract specific error message from API response if available
const errorMessage = data.message || data.status || statusText;
throw new Error(errorMessage);
}
// Handle successful responses
if (data.status === 'success') {
showNotification('config.json saved successfully!', 'success');
} else {
showNotification('Error saving config.json: ' + (data.message || 'Unknown error'), 'error');
}
})
.catch(error => {
// Preserve the error message that was intentionally thrown
showNotification('Error saving config.json: ' + (error.message || 'An error occurred'), 'error');
});
} catch (e) {
showNotification('Invalid JSON: ' + e.message, 'error');
}
}
function saveSecretsConfig() {
const textarea = document.getElementById('secrets-config-editor');
// Validate JSON first
if (!validateJSON('secrets-config-editor', 'secrets-config-validation')) {
showNotification('Invalid JSON! Please fix errors before saving.', 'error');
return;
}
try {
const config = JSON.parse(textarea.value);
// Save via API
fetch('/api/v3/config/raw/secrets', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
})
.then(async response => {
// Store status and statusText before parsing
const status = response.status;
const statusText = response.statusText;
// Try to parse JSON response
let data;
try {
data = await response.json();
} catch (parseError) {
// If JSON parsing fails, throw generic HTTP error
throw new Error(`HTTP ${status}: ${statusText}`);
}
// Handle non-OK responses
if (!response.ok) {
// Extract specific error message from API response if available
const errorMessage = data.message || data.status || statusText;
throw new Error(errorMessage);
}
// Handle successful responses
if (data.status === 'success') {
showNotification('config_secrets.json saved successfully!', 'success');
} else {
showNotification('Error saving config_secrets.json: ' + (data.message || 'Unknown error'), 'error');
}
})
.catch(error => {
// Preserve the error message that was intentionally thrown
showNotification('Error saving config_secrets.json: ' + (error.message || 'An error occurred'), 'error');
});
} catch (e) {
showNotification('Invalid JSON: ' + e.message, 'error');
}
}
// showNotification is provided by the notification widget (notification.js)
// No local definition needed - uses window.showNotification from the widget
</script>