mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
feat(web): ES-module page lifecycle and one schema field model (stage 1) (#703)
Adds a native ES-module layer to the web UI (core/boot, registry, api, facade; window.LEDMatrix as the one global), a page lifecycle that the Cache tab is converted to as the reference, text/javascript serving and revalidation for unversioned module requests, and src/plugin_system/field_model.py with a parity test against the render_field macro. Also: the cache page toggles its grey 'Not configured' style instead of only adding it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,8 +19,12 @@
|
||||
* state_manager.js, install_manager.js, list_filter.js,
|
||||
* the widget bundle (web_interface/widget_bundle.py),
|
||||
* plugins_manager.js
|
||||
* end of <body>, type=module (deferred, runs last): js/core/boot.js --
|
||||
* window.LEDMatrix and the page registry
|
||||
* Tab partials arrive later through htmx; their inline scripts run on
|
||||
* htmx:afterSwap (js/htmx-config.js).
|
||||
* htmx:afterSwap (js/htmx-config.js). A partial converted to a page module
|
||||
* (data-page root, js/pages/<name>.js) has no inline script; the registry
|
||||
* starts it (js/core/registry.js, docs/WEB_FRONTEND_ARCHITECTURE.md).
|
||||
*
|
||||
* Globals:
|
||||
* window.LEDEscape html / attr / jsStringAttr, the only HTML escaper
|
||||
|
||||
@@ -21,8 +21,12 @@
|
||||
* state_manager.js, install_manager.js, list_filter.js,
|
||||
* the widget bundle (web_interface/widget_bundle.py),
|
||||
* plugins_manager.js
|
||||
* end of <body>, type=module (deferred, runs last): js/core/boot.js --
|
||||
* window.LEDMatrix and the page registry
|
||||
* Tab partials arrive later through htmx; their inline scripts run on
|
||||
* htmx:afterSwap (js/htmx-config.js).
|
||||
* htmx:afterSwap (js/htmx-config.js). A partial converted to a page module
|
||||
* (data-page root, js/pages/<name>.js) has no inline script; the registry
|
||||
* starts it (js/core/registry.js, docs/WEB_FRONTEND_ARCHITECTURE.md).
|
||||
*
|
||||
* Globals:
|
||||
* window.app the root component: activeTab, plugin tab
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* core/api.js -- one fetch wrapper for the interface's own JSON API.
|
||||
*
|
||||
* const api = createApi();
|
||||
* const body = await api.get('/api/v3/cache/list', { signal });
|
||||
* await api.post('/api/v3/cache/delete', { key }, { signal });
|
||||
*
|
||||
* Every call resolves to the parsed JSON body, or rejects with an ApiError:
|
||||
* error.status the HTTP status (0 when no HTTP answer arrived)
|
||||
* error.body the parsed JSON body, when there was one
|
||||
* error.network true when fetch() itself failed (service restarting)
|
||||
* error.loginRequired true when the optional web login (#683) wants the
|
||||
* user to sign in again; the page is already navigating
|
||||
* to the login form, so callers should show nothing
|
||||
* A body of {"status": "error"} is an error even with HTTP 200: several
|
||||
* endpoints still answer that way.
|
||||
*
|
||||
* Login redirect. base.html wraps window.fetch before any other script runs:
|
||||
* a 401 carrying X-LEDMatrix-Login sends the browser to that login page. This
|
||||
* module calls window.fetch at call time (never a copy taken at import), so
|
||||
* every request made here goes through that same wrapper and gets the same
|
||||
* redirect. isLoginRedirect() is the wrapper's test, used here only to turn
|
||||
* that answer into a quiet `loginRequired` error instead of an error message
|
||||
* that would flash up while the page navigates away.
|
||||
*
|
||||
* An aborted request (ctx.signal from the page registry) rejects with the
|
||||
* DOMException named AbortError, untouched, so callers can ignore it.
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = details.status || 0;
|
||||
this.body = details.body === undefined ? null : details.body;
|
||||
this.network = !!details.network;
|
||||
this.loginRequired = !!details.loginRequired;
|
||||
if (details.cause !== undefined) this.cause = details.cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** True for the optional web login's "sign in again" answer (see base.html). */
|
||||
export function isLoginRedirect(response) {
|
||||
if (!response || response.status !== 401 || !response.headers) return false;
|
||||
const login = response.headers.get('X-LEDMatrix-Login');
|
||||
return !!login && login.charAt(0) === '/' && login.charAt(1) !== '/';
|
||||
}
|
||||
|
||||
/** True for an AbortError from a cancelled request. */
|
||||
export function isAbort(error) {
|
||||
return !!error && error.name === 'AbortError';
|
||||
}
|
||||
|
||||
// Only this interface's own paths: "/api/...", never "//host" or a full URL.
|
||||
function checkPath(url) {
|
||||
if (typeof url !== 'string' || url.charAt(0) !== '/' || url.charAt(1) === '/' ||
|
||||
/[\\\s]/.test(url)) {
|
||||
throw new TypeError('LEDMatrix.api: not a path on this server: ' + String(url));
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {Function} [options.fetch] fetch implementation (tests); default window.fetch at call time
|
||||
*/
|
||||
export function createApi(options = {}) {
|
||||
const doFetch = options.fetch || function(url, init) { return globalThis.fetch(url, init); };
|
||||
|
||||
async function request(method, url, opts = {}) {
|
||||
checkPath(url); // a bug in the caller, not a network failure
|
||||
const init = {
|
||||
method: method,
|
||||
headers: Object.assign({ 'Accept': 'application/json' }, opts.headers || {}),
|
||||
signal: opts.signal,
|
||||
};
|
||||
if (opts.json !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(opts.json);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await doFetch(url, init);
|
||||
} catch (error) {
|
||||
if (isAbort(error)) throw error;
|
||||
throw new ApiError((error && error.message) || 'Network error',
|
||||
{ network: true, cause: error });
|
||||
}
|
||||
|
||||
if (isLoginRedirect(response)) {
|
||||
throw new ApiError('Signing in again', { status: 401, loginRequired: true });
|
||||
}
|
||||
|
||||
let body = null;
|
||||
let parseError = null;
|
||||
try {
|
||||
const text = await response.text();
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch (error) {
|
||||
if (isAbort(error)) throw error;
|
||||
parseError = error;
|
||||
}
|
||||
|
||||
const message = body && typeof body.message === 'string' && body.message
|
||||
? body.message : null;
|
||||
if (!response.ok) {
|
||||
throw new ApiError(message || ('HTTP ' + response.status),
|
||||
{ status: response.status, body: body, cause: parseError || undefined });
|
||||
}
|
||||
if (parseError || body === null || typeof body !== 'object') {
|
||||
throw new ApiError('Unreadable response from the server (HTTP ' + response.status + ')',
|
||||
{ status: response.status, cause: parseError || undefined });
|
||||
}
|
||||
if (body.status === 'error') {
|
||||
throw new ApiError(message || 'The request failed', { status: response.status, body: body });
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
return {
|
||||
request: request,
|
||||
get: function(url, opts) { return request('GET', url, opts); },
|
||||
post: function(url, json, opts) { return request('POST', url, Object.assign({}, opts, { json: json })); },
|
||||
put: function(url, json, opts) { return request('PUT', url, Object.assign({}, opts, { json: json })); },
|
||||
del: function(url, opts) { return request('DELETE', url, opts); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* core/boot.js -- the entry module. base.html loads it with
|
||||
* <script type="module">; everything else under js/core/ and js/pages/ is
|
||||
* reached through imports from here. No bundler: the Pi serves these files
|
||||
* as they are (see docs/WEB_FRONTEND_ARCHITECTURE.md).
|
||||
*
|
||||
* Modules run deferred, after the HTML is parsed, so a tab partial may have
|
||||
* been swapped in before this file runs. registry.start() mounts any page
|
||||
* already on the screen, so the order does not matter.
|
||||
*/
|
||||
import { createApi } from './api.js';
|
||||
import { createFacade, installFacade } from './facade.js';
|
||||
import { createRegistry } from './registry.js';
|
||||
|
||||
const api = createApi();
|
||||
const registry = createRegistry({
|
||||
context: {
|
||||
api: api,
|
||||
notify: function(message, type) { return window.LEDMatrix.notify(message, type); },
|
||||
},
|
||||
});
|
||||
const facade = installFacade(window, createFacade(window, api, registry));
|
||||
|
||||
// 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'); });
|
||||
|
||||
// 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");
|
||||
|
||||
registry.start();
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* core/facade.js -- window.LEDMatrix, the one global the module code adds.
|
||||
*
|
||||
* LEDMatrix.api core/api.js: get/post/put/del against /api/v3
|
||||
* LEDMatrix.pages the page registry: register(name, moduleOrLoader),
|
||||
* refresh(), list()
|
||||
* LEDMatrix.notify(m, t) window.showNotification, looked up at call time
|
||||
* LEDMatrix.escape window.LEDEscape (app-early.js)
|
||||
* LEDMatrix.widgets window.LEDMatrixWidgets (the widget registry)
|
||||
* LEDMatrix.deprecate(...) keep an old window.* name working (see below)
|
||||
*
|
||||
* Plugins and third-party pages should reach the interface through this
|
||||
* object. The classic scripts still define their own window.* names; as each
|
||||
* one moves into a module, its old name stays as an alias made with
|
||||
* deprecate(), which warns once in the console and forwards to the new code.
|
||||
* Nothing is removed until a release announces it.
|
||||
*
|
||||
* escape, widgets and notify are read through at call time on purpose: the
|
||||
* classic scripts that define them are deferred and may load after this
|
||||
* module, and a plugin may replace showNotification.
|
||||
*/
|
||||
|
||||
export const FACADE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Define window[name] as a deprecated alias of `target`.
|
||||
*
|
||||
* A function target is wrapped: the first call logs one console warning
|
||||
* naming the replacement, and every call forwards to `target`. Any other
|
||||
* value becomes a read-only getter with the same one-time warning.
|
||||
* An existing non-configurable property is left alone (returns false).
|
||||
*/
|
||||
export function defineDeprecatedAlias(win, name, target, replacement, logger) {
|
||||
const log = logger || win.console || console;
|
||||
const existing = Object.getOwnPropertyDescriptor(win, name);
|
||||
if (existing && !existing.configurable) return false;
|
||||
let warned = false;
|
||||
function warn() {
|
||||
if (warned) return;
|
||||
warned = true;
|
||||
log.warn('[LEDMatrix] window.' + name + ' is deprecated' +
|
||||
(replacement ? '; use ' + replacement + ' instead' : '') + '.');
|
||||
}
|
||||
if (typeof target === 'function') {
|
||||
const alias = function() {
|
||||
warn();
|
||||
return target.apply(this, arguments);
|
||||
};
|
||||
Object.defineProperty(win, name, { value: alias, configurable: true, writable: true });
|
||||
} else {
|
||||
Object.defineProperty(win, name, {
|
||||
get: function() { warn(); return target; },
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the facade object. `win` is the window it reads the classic globals
|
||||
* from; `api` and `registry` come from core/api.js and core/registry.js.
|
||||
*/
|
||||
export function createFacade(win, api, registry, logger) {
|
||||
const log = logger || win.console || console;
|
||||
const pages = Object.freeze({
|
||||
register: registry.register,
|
||||
refresh: registry.refresh,
|
||||
list: registry.list,
|
||||
});
|
||||
const facade = {
|
||||
version: FACADE_VERSION,
|
||||
api: api,
|
||||
pages: pages,
|
||||
notify: function(message, type) {
|
||||
const notify = win.showNotification;
|
||||
if (typeof notify === 'function') return notify(message, type || 'info');
|
||||
(type === 'error' ? log.error : log.log).call(log, '[LEDMatrix] ' + message);
|
||||
return undefined;
|
||||
},
|
||||
deprecate: function(name, target, replacement) {
|
||||
return defineDeprecatedAlias(win, name, target, replacement, log);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(facade, 'escape', { get: function() { return win.LEDEscape; }, enumerable: true });
|
||||
Object.defineProperty(facade, 'widgets', { get: function() { return win.LEDMatrixWidgets; }, enumerable: true });
|
||||
return Object.freeze(facade);
|
||||
}
|
||||
|
||||
/** Publish the facade as window.LEDMatrix (replacing an earlier one, if any). */
|
||||
export function installFacade(win, facade) {
|
||||
Object.defineProperty(win, 'LEDMatrix', { value: facade, configurable: true, enumerable: true });
|
||||
return facade;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"//": "Marks the files in this directory as ES modules for node (the JS tests import them). Browsers ignore this file: base.html loads them with <script type=\"module\">. See docs/WEB_FRONTEND_ARCHITECTURE.md.",
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* core/registry.js -- the page lifecycle for HTMX-swapped partials.
|
||||
*
|
||||
* A partial marks its root element with data-page="<name>". A page module
|
||||
* (static/v3/js/pages/<name>.js) exports:
|
||||
*
|
||||
* init(root, ctx) wire the page up. Runs once per root element.
|
||||
* destroy(root, ctx) optional; undo anything `ctx.signal` does not.
|
||||
*
|
||||
* ctx is a per-mount object holding the shared services passed to
|
||||
* createRegistry({ context }) (boot.js passes `api` and `notify`) plus:
|
||||
* ctx.root the data-page element
|
||||
* ctx.name the page name
|
||||
* ctx.signal an AbortSignal aborted on destroy. Pass it to
|
||||
* addEventListener(type, fn, { signal }) and to fetch(), and
|
||||
* the listeners go away and requests are cancelled with no
|
||||
* bookkeeping in the page.
|
||||
* ctx.state a plain object the page may keep its own state in
|
||||
*
|
||||
* Mounting is idempotent: a root that is already mounted is never initialised
|
||||
* twice, which is the guarantee the old inline <script> blocks could not give
|
||||
* (htmx-config.js re-ran them on every swap).
|
||||
*
|
||||
* Wiring (start()):
|
||||
* htmx:beforeSwap destroys every mounted page inside the swap target, unless
|
||||
* the swap was vetoed (detail.shouldSwap false). Listening
|
||||
* on `document` rather than `body` puts this after the
|
||||
* body-level handlers in htmx-config.js that can veto it.
|
||||
* htmx:afterSwap destroys any mounted page whose root has left the
|
||||
* document (a swap styled outerHTML, a panel removed by
|
||||
* Alpine), then mounts every data-page root not yet mounted.
|
||||
* refresh() the same as afterSwap, for content inserted without htmx
|
||||
* (base.html's loadPartialDirect fallback calls it).
|
||||
*
|
||||
* No DOM globals are read at import time, so node tests can import this file
|
||||
* and hand createRegistry() a jsdom document.
|
||||
*/
|
||||
|
||||
export const PAGE_ATTRIBUTE = 'data-page';
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {Document} [options.document] the document to wire (default: globalThis.document)
|
||||
* @param {object} [options.context] services copied onto every page's ctx
|
||||
* @param {{error: Function}} [options.logger]
|
||||
*/
|
||||
export function createRegistry(options = {}) {
|
||||
const doc = options.document || globalThis.document;
|
||||
const logger = options.logger || console;
|
||||
const services = options.context || {};
|
||||
// The document's own AbortController: an element only accepts a signal
|
||||
// from its own realm (it matters for jsdom in the tests, not in a browser).
|
||||
const Controller = (doc && doc.defaultView && doc.defaultView.AbortController) || globalThis.AbortController;
|
||||
const selector = '[' + PAGE_ATTRIBUTE + ']';
|
||||
|
||||
/** name -> { load: () => Promise<module> | module, module: object|null } */
|
||||
const definitions = new Map();
|
||||
/** root element -> mount entry */
|
||||
const mounted = new Map();
|
||||
let started = false;
|
||||
|
||||
function register(name, moduleOrLoader) {
|
||||
if (typeof name !== 'string' || !name) {
|
||||
throw new TypeError('registerPage: a page needs a non-empty name');
|
||||
}
|
||||
if (definitions.has(name)) {
|
||||
throw new Error('registerPage: "' + name + '" is already registered');
|
||||
}
|
||||
const isLoader = typeof moduleOrLoader === 'function';
|
||||
if (!isLoader && !(moduleOrLoader && typeof moduleOrLoader.init === 'function')) {
|
||||
throw new TypeError('registerPage: "' + name + '" needs a module with init(), or a loader function');
|
||||
}
|
||||
definitions.set(name, {
|
||||
load: isLoader ? moduleOrLoader : null,
|
||||
module: isLoader ? null : moduleOrLoader,
|
||||
});
|
||||
// A partial may already be on the page (it arrived before this page
|
||||
// was registered); mount it now rather than waiting for the next swap.
|
||||
if (started) scan(doc);
|
||||
}
|
||||
|
||||
async function resolve(definition) {
|
||||
if (!definition.module) {
|
||||
const loaded = await definition.load();
|
||||
// `import()` resolves to a namespace object; a loader may also
|
||||
// return the module object itself.
|
||||
definition.module = loaded && loaded.default && typeof loaded.init !== 'function'
|
||||
? loaded.default : loaded;
|
||||
if (!definition.module || typeof definition.module.init !== 'function') {
|
||||
throw new TypeError('page module has no init()');
|
||||
}
|
||||
}
|
||||
return definition.module;
|
||||
}
|
||||
|
||||
function mount(root) {
|
||||
const existing = mounted.get(root);
|
||||
if (existing) return existing.ready;
|
||||
const name = root.getAttribute(PAGE_ATTRIBUTE);
|
||||
const definition = definitions.get(name);
|
||||
if (!definition) {
|
||||
// Not an error: a page module may register later (scan again then).
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const controller = new Controller();
|
||||
const ctx = Object.assign({}, services,
|
||||
{ root: root, name: name, signal: controller.signal, state: {} });
|
||||
const entry = { name: name, root: root, ctx: ctx, controller: controller,
|
||||
module: null, initialised: false, destroyed: false, ready: null };
|
||||
mounted.set(root, entry);
|
||||
entry.ready = resolve(definition).then(function(module) {
|
||||
// Destroyed (swapped away) while the module was still loading.
|
||||
if (entry.destroyed) return false;
|
||||
entry.module = module;
|
||||
module.init(root, ctx);
|
||||
entry.initialised = true;
|
||||
return true;
|
||||
}).catch(function(error) {
|
||||
logger.error('[LEDMatrix.pages] ' + name + ' failed to start:', error);
|
||||
// Leave it mounted (but inert) so it is not retried on every swap;
|
||||
// the next swap of that partial gets a fresh root and a fresh try.
|
||||
return false;
|
||||
});
|
||||
return entry.ready;
|
||||
}
|
||||
|
||||
function unmount(root) {
|
||||
const entry = mounted.get(root);
|
||||
if (!entry) return false;
|
||||
mounted.delete(root);
|
||||
entry.destroyed = true;
|
||||
if (entry.initialised && typeof entry.module.destroy === 'function') {
|
||||
try {
|
||||
entry.module.destroy(root, entry.ctx);
|
||||
} catch (error) {
|
||||
logger.error('[LEDMatrix.pages] ' + entry.name + ' destroy failed:', error);
|
||||
}
|
||||
}
|
||||
// After destroy(), so the page can still use its signal while tearing down.
|
||||
entry.controller.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
function rootsIn(container) {
|
||||
if (!container || typeof container.querySelectorAll !== 'function') return [];
|
||||
const roots = Array.from(container.querySelectorAll(selector));
|
||||
if (typeof container.matches === 'function' && container.matches(selector)) {
|
||||
roots.unshift(container);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Mount every data-page root inside `container` (default: the document). */
|
||||
function scan(container) {
|
||||
return Promise.all(rootsIn(container || doc).map(mount));
|
||||
}
|
||||
|
||||
/** Destroy every mounted page whose root is `container` or inside it. */
|
||||
function release(container) {
|
||||
if (!container || typeof container.contains !== 'function') return 0;
|
||||
let count = 0;
|
||||
Array.from(mounted.keys()).forEach(function(root) {
|
||||
if (container.contains(root) && unmount(root)) count++;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Destroy every mounted page whose root is no longer in the document. */
|
||||
function sweep() {
|
||||
let count = 0;
|
||||
Array.from(mounted.keys()).forEach(function(root) {
|
||||
if (!root.isConnected && unmount(root)) count++;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
sweep();
|
||||
return scan(doc);
|
||||
}
|
||||
|
||||
function onBeforeSwap(event) {
|
||||
const detail = event.detail || {};
|
||||
if (detail.shouldSwap === false) return;
|
||||
release(detail.target);
|
||||
}
|
||||
|
||||
function onAfterSwap() {
|
||||
refresh();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return refresh();
|
||||
started = true;
|
||||
doc.addEventListener('htmx:beforeSwap', onBeforeSwap);
|
||||
doc.addEventListener('htmx:afterSwap', onAfterSwap);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
doc.removeEventListener('htmx:beforeSwap', onBeforeSwap);
|
||||
doc.removeEventListener('htmx:afterSwap', onAfterSwap);
|
||||
Array.from(mounted.keys()).forEach(unmount);
|
||||
}
|
||||
|
||||
/** Snapshot for debugging and tests: [{ name, root, initialised }]. */
|
||||
function list() {
|
||||
return Array.from(mounted.values()).map(function(entry) {
|
||||
return { name: entry.name, root: entry.root, initialised: entry.initialised };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
register: register,
|
||||
mount: mount,
|
||||
unmount: unmount,
|
||||
scan: scan,
|
||||
release: release,
|
||||
sweep: sweep,
|
||||
refresh: refresh,
|
||||
start: start,
|
||||
stop: stop,
|
||||
list: list,
|
||||
has: function(name) { return definitions.has(name); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* pages/cache.js -- the Cache tab (templates/v3/partials/cache.html).
|
||||
*
|
||||
* The reference conversion for docs/WEB_FRONTEND_ARCHITECTURE.md: the partial
|
||||
* carries no <script>; its root is <div data-page="cache">, and the page
|
||||
* registry (core/registry.js) calls init() once when it appears and destroy()
|
||||
* when it is swapped away. Every listener is registered with ctx.signal and
|
||||
* every request carries it, so destroy has nothing left to undo by hand.
|
||||
*
|
||||
* ctx (from core/boot.js): ctx.api (core/api.js), ctx.notify, ctx.signal,
|
||||
* ctx.state.
|
||||
*/
|
||||
|
||||
const LIST_URL = '/api/v3/cache/list';
|
||||
const DELETE_URL = '/api/v3/cache/delete';
|
||||
|
||||
// The mounted page, for the deprecated window.deleteCacheFile alias (boot.js).
|
||||
let active = null;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function elements(root) {
|
||||
return {
|
||||
dir: root.querySelector('#cache-dir'),
|
||||
tbody: root.querySelector('#cache-files-tbody'),
|
||||
empty: root.querySelector('#cache-empty'),
|
||||
error: root.querySelector('#cache-error'),
|
||||
errorMessage: root.querySelector('#cache-error-message'),
|
||||
refresh: root.querySelector('#refresh-cache-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, iconClass, text) {
|
||||
const row = el(doc, 'tr');
|
||||
const cell = el(doc, 'td', 'px-6 py-8 text-center text-gray-500');
|
||||
cell.colSpan = 5;
|
||||
cell.append(el(doc, 'i', iconClass), el(doc, 'p', null, text));
|
||||
row.append(cell);
|
||||
return row;
|
||||
}
|
||||
|
||||
function ageClass(seconds) {
|
||||
if (seconds < 300) return 'text-green-600 font-medium'; // under 5 minutes
|
||||
if (seconds < 3600) return 'text-yellow-600'; // under an hour
|
||||
return 'text-red-600';
|
||||
}
|
||||
|
||||
function formatModified(value) {
|
||||
return new Date(value).toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function cacheRow(doc, file) {
|
||||
const row = el(doc, 'tr', 'hover:bg-gray-50');
|
||||
|
||||
const keyCell = el(doc, 'td', 'px-6 py-4 whitespace-nowrap');
|
||||
keyCell.append(el(doc, 'div', 'text-sm font-medium text-gray-900 font-mono', String(file.key)),
|
||||
el(doc, 'div', 'text-xs text-gray-500', String(file.filename)));
|
||||
|
||||
const ageCell = el(doc, 'td', 'px-6 py-4 whitespace-nowrap');
|
||||
ageCell.append(el(doc, 'span', 'text-sm ' + ageClass(file.age_seconds), String(file.age_display)));
|
||||
|
||||
const sizeCell = el(doc, 'td', 'px-6 py-4 whitespace-nowrap');
|
||||
sizeCell.append(el(doc, 'span', 'text-sm text-gray-600', String(file.size_display)));
|
||||
|
||||
const modifiedCell = el(doc, 'td', 'px-6 py-4 whitespace-nowrap');
|
||||
modifiedCell.append(el(doc, 'span', 'text-sm text-gray-600', formatModified(file.modified_datetime)));
|
||||
|
||||
const actionCell = el(doc, 'td', 'px-6 py-4 whitespace-nowrap text-right text-sm font-medium');
|
||||
const button = el(doc, 'button',
|
||||
'text-red-600 hover:text-red-900 px-3 py-1 rounded hover:bg-red-50 transition-colors');
|
||||
button.type = 'button';
|
||||
button.dataset.cacheKey = String(file.key);
|
||||
button.title = 'Delete cache file';
|
||||
button.setAttribute('aria-label', 'Delete cache file ' + file.key);
|
||||
button.append(el(doc, 'i', 'fas fa-trash mr-1'), doc.createTextNode('Delete'));
|
||||
actionCell.append(button);
|
||||
|
||||
row.append(keyCell, ageCell, sizeCell, modifiedCell, actionCell);
|
||||
return row;
|
||||
}
|
||||
|
||||
function showError(ctx, message) {
|
||||
const { tbody, empty, error, errorMessage } = ctx.state.els;
|
||||
tbody.replaceChildren();
|
||||
empty.classList.add('hidden');
|
||||
error.classList.remove('hidden');
|
||||
errorMessage.textContent = message;
|
||||
}
|
||||
|
||||
function render(ctx, data) {
|
||||
const { tbody, empty, error, dir } = ctx.state.els;
|
||||
const doc = ctx.root.ownerDocument;
|
||||
error.classList.add('hidden');
|
||||
|
||||
dir.textContent = data.cache_dir || 'Not configured';
|
||||
// Toggled, not added: a refresh keeps the same element, so a directory
|
||||
// that appears later must lose the grey "Not configured" style.
|
||||
dir.classList.toggle('text-gray-500', !data.cache_dir);
|
||||
|
||||
const files = Array.isArray(data.cache_files) ? data.cache_files : [];
|
||||
if (!files.length) {
|
||||
tbody.replaceChildren();
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
tbody.replaceChildren(...files.map(function(file) { return cacheRow(doc, file); }));
|
||||
}
|
||||
|
||||
/** Fetch and draw the cache list. A newer load supersedes an older one. */
|
||||
export function load(ctx) {
|
||||
const { tbody, empty, error } = ctx.state.els;
|
||||
const seq = (ctx.state.seq || 0) + 1;
|
||||
ctx.state.seq = seq;
|
||||
tbody.replaceChildren(messageRow(ctx.root.ownerDocument, 'fas fa-spinner fa-spin text-2xl mb-2',
|
||||
'Loading cache files...'));
|
||||
empty.classList.add('hidden');
|
||||
error.classList.add('hidden');
|
||||
|
||||
return ctx.api.get(LIST_URL, { signal: ctx.signal }).then(function(body) {
|
||||
if (ctx.state.seq !== seq) return;
|
||||
render(ctx, body.data || {});
|
||||
}).catch(function(err) {
|
||||
if (isQuiet(err) || ctx.state.seq !== seq) return;
|
||||
showError(ctx, err.network || !err.status
|
||||
? 'Error loading cache files: ' + err.message
|
||||
: (err.message || 'Failed to load cache files'));
|
||||
});
|
||||
}
|
||||
|
||||
/** Ask, then delete one cache entry and reload the list. */
|
||||
export function deleteEntry(ctx, key) {
|
||||
const win = ctx.root.ownerDocument.defaultView;
|
||||
if (!win.confirm('Are you sure you want to delete the cache file for "' + key + '"?')) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return ctx.api.post(DELETE_URL, { key: key }, { signal: ctx.signal }).then(function(body) {
|
||||
ctx.notify(body.message || 'Cache file deleted successfully', 'success');
|
||||
load(ctx);
|
||||
return true;
|
||||
}).catch(function(err) {
|
||||
if (isQuiet(err)) return false;
|
||||
ctx.notify(err.network || !err.status
|
||||
? 'Error deleting cache file: ' + err.message
|
||||
: (err.message || 'Failed to delete cache file'), 'error');
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function init(root, ctx) {
|
||||
const els = elements(root);
|
||||
ctx.state.els = els;
|
||||
|
||||
els.refresh.addEventListener('click', function() { load(ctx); }, { signal: ctx.signal });
|
||||
// One delegated listener for every row's Delete button, however often
|
||||
// the rows are redrawn.
|
||||
els.tbody.addEventListener('click', function(event) {
|
||||
const button = event.target.closest('button[data-cache-key]');
|
||||
if (button && els.tbody.contains(button)) deleteEntry(ctx, button.dataset.cacheKey);
|
||||
}, { signal: ctx.signal });
|
||||
|
||||
active = ctx;
|
||||
load(ctx);
|
||||
}
|
||||
|
||||
export function destroy(root, ctx) {
|
||||
if (active === ctx) active = null;
|
||||
}
|
||||
|
||||
/** The old window.deleteCacheFile(key), kept as a deprecated alias (boot.js). */
|
||||
export function deleteCacheFile(key) {
|
||||
return active ? deleteEntry(active, key) : Promise.resolve(false);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"//": "Marks the files in this directory as ES modules for node (the JS tests import them). Browsers ignore this file: base.html loads them with <script type=\"module\">. See docs/WEB_FRONTEND_ARCHITECTURE.md.",
|
||||
"type": "module"
|
||||
}
|
||||
@@ -25,8 +25,12 @@
|
||||
* state_manager.js, install_manager.js, list_filter.js,
|
||||
* the widget bundle (web_interface/widget_bundle.py),
|
||||
* plugins_manager.js
|
||||
* end of <body>, type=module (deferred, runs last): js/core/boot.js --
|
||||
* window.LEDMatrix and the page registry
|
||||
* Tab partials arrive later through htmx; their inline scripts run on
|
||||
* htmx:afterSwap (js/htmx-config.js).
|
||||
* htmx:afterSwap (js/htmx-config.js). A partial converted to a page module
|
||||
* (data-page root, js/pages/<name>.js) has no inline script; the registry
|
||||
* starts it (js/core/registry.js, docs/WEB_FRONTEND_ARCHITECTURE.md).
|
||||
*
|
||||
* Layout: a few handlers defined up front, outside any IIFE, because the
|
||||
* cards and other scripts call them through window (configurePlugin,
|
||||
|
||||
Reference in New Issue
Block a user