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:
Chuck
2026-10-01 10:00:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 795834811f
commit c6701ac00d
27 changed files with 3006 additions and 175 deletions
+25 -1
View File
@@ -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')
+5 -1
View File
@@ -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
+5 -1
View File
@@ -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
+128
View File
@@ -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); },
};
}
+33
View File
@@ -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();
+93
View File
@@ -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"
}
+228
View File
@@ -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); },
};
}
+188
View File
@@ -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"
}
+5 -1
View File
@@ -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,
+12 -1
View File
@@ -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) -->
+5 -166
View File
@@ -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>