mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user