mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +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:
+25
-1
@@ -1,6 +1,7 @@
|
||||
from flask import Flask, request, redirect, url_for, jsonify, Response, send_from_directory
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
@@ -49,6 +50,15 @@ _VCGENCMD = shutil.which('vcgencmd')
|
||||
from web_interface import display_preview
|
||||
from web_interface.system_metrics import collect_system_metrics
|
||||
|
||||
# Static files get their Content-Type from the mimetypes table, which reads the
|
||||
# host's own files (/etc/mime.types, the Windows registry). Browsers refuse to
|
||||
# run a <script type="module"> served as anything but JavaScript (the
|
||||
# static/v3/js/core and js/pages modules), and X-Content-Type-Options: nosniff
|
||||
# below makes them strict about classic scripts too. Pin it rather than trust
|
||||
# whatever the host says.
|
||||
mimetypes.add_type('text/javascript', '.js')
|
||||
mimetypes.add_type('text/javascript', '.mjs')
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.urandom(24)
|
||||
@@ -618,6 +628,13 @@ def _apply_gzip(response, compressed):
|
||||
return response
|
||||
|
||||
|
||||
def _is_unversioned_static_script():
|
||||
"""A /static/ .js or .mjs request with no ``v`` (content version) parameter."""
|
||||
return (request.path.startswith('/static/')
|
||||
and request.path.endswith(('.js', '.mjs'))
|
||||
and 'v' not in request.args)
|
||||
|
||||
|
||||
# Add security headers and caching to all responses
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
@@ -629,7 +646,14 @@ def add_security_headers(response):
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
|
||||
# Add caching headers for static assets
|
||||
if request.path.startswith(_VERSIONED_ASSET_PREFIXES):
|
||||
if _is_unversioned_static_script():
|
||||
# A script requested without the ?v= content version. ES modules
|
||||
# (static/v3/js/core, js/pages) import each other by plain relative
|
||||
# URL, which url_for never sees, so a year-long immutable copy would
|
||||
# keep running the old module after an update. Let the browser keep
|
||||
# it but revalidate (a 304 when unchanged).
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
elif request.path.startswith(_VERSIONED_ASSET_PREFIXES):
|
||||
# Cache static assets for 1 year (with versioning via query params)
|
||||
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||
response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -228,6 +228,9 @@
|
||||
el.setAttribute('data-loaded', 'true');
|
||||
if (typeof htmx !== 'undefined') htmx.process(el);
|
||||
if (window.Alpine) window.Alpine.initTree(el);
|
||||
// No htmx swap events here, so start converted pages
|
||||
// (data-page roots) directly; see js/core/registry.js.
|
||||
if (window.LEDMatrix && window.LEDMatrix.pages) window.LEDMatrix.pages.refresh();
|
||||
if (id === 'plugins-content' && window.initPluginsPage) {
|
||||
if (window.pluginManager) {
|
||||
window.pluginManager.initialized = false;
|
||||
@@ -948,7 +951,15 @@
|
||||
or when a card is clicked, so it must follow all of them. No other
|
||||
script defines the same globals. -->
|
||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}" defer></script>
|
||||
|
||||
|
||||
<!-- ES modules: window.LEDMatrix and the page lifecycle (js/core/), and
|
||||
the converted tab pages (js/pages/), loaded on first use. Module
|
||||
scripts are deferred like the scripts above and run after them; they
|
||||
rely on none of them at load time, and a tab partial that arrived
|
||||
first is started when the registry starts. Served as-is, no bundler:
|
||||
see docs/WEB_FRONTEND_ARCHITECTURE.md. -->
|
||||
<script type="module" src="{{ url_for('static', filename='v3/js/core/boot.js') }}"></script>
|
||||
|
||||
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
|
||||
deferred widget's window assignments always shadowed the inline
|
||||
copies that used to sit here, so the duplicates were removed) -->
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
{# No inline script: static/v3/js/pages/cache.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="cache">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Cache Management</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">View and manage cached API responses. Cache files help reduce API calls and improve performance.</p>
|
||||
@@ -11,7 +14,7 @@
|
||||
<p class="text-sm font-medium text-blue-900">Cache Directory</p>
|
||||
<p id="cache-dir" class="text-sm text-blue-700 font-mono mt-1">Loading...</p>
|
||||
</div>
|
||||
<button id="refresh-cache-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
|
||||
<button type="button" id="refresh-cache-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
|
||||
<i class="fas fa-sync-alt mr-2"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
@@ -54,167 +57,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Scoped: this script runs at global scope after every HTMX swap, and the Logs
|
||||
// partial has helpers with the same names (showError, escapeHtml). Only
|
||||
// deleteCacheFile, which the row buttons call from onclick, is exported.
|
||||
(function() {
|
||||
loadCacheFiles();
|
||||
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
|
||||
|
||||
function loadCacheFiles() {
|
||||
const tbody = document.getElementById('cache-files-tbody');
|
||||
const emptyState = document.getElementById('cache-empty');
|
||||
const errorState = document.getElementById('cache-error');
|
||||
const errorMessage = document.getElementById('cache-error-message');
|
||||
|
||||
// Show loading state
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
|
||||
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
|
||||
<p>Loading cache files...</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
emptyState.classList.add('hidden');
|
||||
errorState.classList.add('hidden');
|
||||
|
||||
fetch('/api/v3/cache/list')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
displayCacheFiles(data.data);
|
||||
updateCacheInfo(data.data.cache_dir);
|
||||
} else {
|
||||
showError(data.message || 'Failed to load cache files');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showError('Error loading cache files: ' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function displayCacheFiles(data) {
|
||||
const tbody = document.getElementById('cache-files-tbody');
|
||||
const emptyState = document.getElementById('cache-empty');
|
||||
const errorState = document.getElementById('cache-error');
|
||||
|
||||
errorState.classList.add('hidden');
|
||||
|
||||
if (!data.cache_files || data.cache_files.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.cache_files.forEach(cacheFile => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-gray-50';
|
||||
|
||||
// Format modified time
|
||||
const modifiedDate = new Date(cacheFile.modified_datetime);
|
||||
const modifiedStr = modifiedDate.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
// Age color coding
|
||||
let ageClass = 'text-gray-600';
|
||||
if (cacheFile.age_seconds < 300) { // Less than 5 minutes
|
||||
ageClass = 'text-green-600 font-medium';
|
||||
} else if (cacheFile.age_seconds < 3600) { // Less than 1 hour
|
||||
ageClass = 'text-yellow-600';
|
||||
} else { // Older than 1 hour
|
||||
ageClass = 'text-red-600';
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900 font-mono">${escapeHtml(cacheFile.key)}</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(cacheFile.filename)}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm ${ageClass}">${escapeHtml(cacheFile.age_display)}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-gray-600">${escapeHtml(cacheFile.size_display)}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-gray-600">${escapeHtml(modifiedStr)}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onclick="deleteCacheFile(this.dataset.cacheKey)"
|
||||
data-cache-key="${escapeHtml(cacheFile.key)}"
|
||||
class="text-red-600 hover:text-red-900 px-3 py-1 rounded hover:bg-red-50 transition-colors"
|
||||
title="Delete cache file">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function updateCacheInfo(cacheDir) {
|
||||
const cacheDirEl = document.getElementById('cache-dir');
|
||||
if (cacheDir) {
|
||||
cacheDirEl.textContent = cacheDir;
|
||||
} else {
|
||||
cacheDirEl.textContent = 'Not configured';
|
||||
cacheDirEl.classList.add('text-gray-500');
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCacheFile(key) {
|
||||
if (!confirm(`Are you sure you want to delete the cache file for "${key}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/v3/cache/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ key: key })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
showNotification(data.message || 'Cache file deleted successfully', 'success');
|
||||
// Reload cache files list
|
||||
loadCacheFiles();
|
||||
} else {
|
||||
showNotification(data.message || 'Failed to delete cache file', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showNotification('Error deleting cache file: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const tbody = document.getElementById('cache-files-tbody');
|
||||
const errorState = document.getElementById('cache-error');
|
||||
const errorMessage = document.getElementById('cache-error-message');
|
||||
const emptyState = document.getElementById('cache-empty');
|
||||
|
||||
tbody.innerHTML = '';
|
||||
emptyState.classList.add('hidden');
|
||||
errorState.classList.remove('hidden');
|
||||
errorMessage.textContent = message;
|
||||
}
|
||||
|
||||
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||
|
||||
window.deleteCacheFile = deleteCacheFile;
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user