fix(web): widget cache-busting, dead frontend code, and dependency pins (#656)

- Plugin-supplied widgets load as /static/plugin-widgets/...js?v=<plugin
  version>, so an update isn't hidden behind the year-long immutable cache.
- Fire-and-forget loadInstalledPlugins() calls catch the rejection it has
  already reported, so the global handler no longer adds a second toast.
- Timezone picker renders again when the General partial is re-injected.
- Remove dead code: executePluginAction's six plugin-id fallbacks and
  [DEBUG] logging, window.currentPluginConfig and every read of it, the
  file-upload JSON delete branch, unused PluginAPI / PluginInstallManager /
  PluginStateManager helpers, loadPluginWidgetsFromManifest, the stale
  install_manager.js and LEDVisibility fallbacks, error_handler.js's global
  escapeHtml, 13 unused CSS rules, and stale comments/no-op returns.
- pytz < 2027, psutil < 7 in requirements-test.txt, pytest-cov < 8.
- Pin anthropics/claude-code-action to the commit v1 resolves to.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 10:40:52 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 65d82580bc
commit 0e9e2cabba
24 changed files with 109 additions and 579 deletions
@@ -227,7 +227,7 @@
*/
window.handleFiles = async function(fieldId, files) {
const uploadConfig = window.getUploadConfig ? window.getUploadConfig(fieldId) : {};
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
const pluginId = uploadConfig.plugin_id || 'static-image';
const maxFiles = uploadConfig.max_files || 10;
const maxSizeMB = uploadConfig.max_size_mb || 5;
const fileType = uploadConfig.file_type || 'image';
@@ -333,30 +333,24 @@
* @param {string} pluginId - Plugin ID
*/
window.deleteUploadedImage = async function(fieldId, imageId, pluginId) {
return window.deleteUploadedFile(fieldId, imageId, pluginId, 'image', null);
return window.deleteUploadedFile(fieldId, imageId, pluginId);
};
/**
* Delete uploaded file (generic)
* Delete an uploaded plugin asset
* @param {string} fieldId - Field ID
* @param {string} fileId - File ID
* @param {string} pluginId - Plugin ID
* @param {string} fileType - File type ('image' or 'json')
* @param {string|null} customDeleteEndpoint - Custom delete endpoint
*/
window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) {
const fileTypeLabel = fileType === 'json' ? 'file' : 'image';
if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) {
window.deleteUploadedFile = async function(fieldId, fileId, pluginId) {
if (!confirm('Are you sure you want to delete this image?')) {
return;
}
try {
const deleteEndpoint = customDeleteEndpoint || (fileType === 'json' ? '/api/v3/plugins/of-the-day/json/delete' : '/api/v3/plugins/assets/delete');
const requestBody = fileType === 'json'
? { file_id: fileId }
: { plugin_id: pluginId, image_id: fileId };
const response = await fetch(deleteEndpoint, {
const requestBody = { plugin_id: pluginId, image_id: fileId };
const response = await fetch('/api/v3/plugins/assets/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
@@ -381,7 +375,7 @@
window.updateImageList(fieldId, newFiles);
}
window.showNotification(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
window.showNotification('Image deleted successfully', 'success');
} else {
window.showNotification(`Delete failed: ${data.message}`, 'error');
}
@@ -392,14 +386,13 @@
};
/**
* Get upload configuration for a file upload field.
* Priority: 1) data attributes on the file input element (server-rendered),
* 2) schema lookup via window.currentPluginConfig (client-rendered).
* Get upload configuration for a file upload field, read from the data
* attributes the server renders on the file input or its drop zone.
* @param {string} fieldId - Field ID
* @returns {Object} Upload configuration
*/
window.getUploadConfig = function(fieldId) {
// Strategy 1: Read from data attributes on the file input element or
// Read from data attributes on the file input element or
// the drop zone wrapper (which survives progress-helper re-renders).
// Accept any upload-related data attribute — not just pluginId.
const configSource = getConfigSourceElement(fieldId);
@@ -417,45 +410,6 @@
return config;
}
// Strategy 2: Extract config from schema (client-side rendered forms)
const schema = window.currentPluginConfig?.schema;
if (!schema || !schema.properties) return {};
// Find the property that matches this fieldId
// FieldId is like "image_config_images" for "image_config.images" (client-side)
// or "static-image-images" for plugin "static-image", field "images" (server-side)
const key = fieldId.replace(/_/g, '.');
const keys = key.split('.');
let prop = schema.properties;
for (const k of keys) {
if (prop && prop[k]) {
prop = prop[k];
if (prop.properties && prop.type === 'object') {
prop = prop.properties;
} else if (prop.type === 'array' && prop['x-widget'] === 'file-upload') {
break;
} else {
break;
}
}
}
// If we found an array with x-widget, get its config
if (prop && prop.type === 'array' && prop['x-widget'] === 'file-upload') {
return prop['x-upload-config'] || {};
}
// Try to find nested images array (legacy fallback)
if (schema.properties && schema.properties.image_config &&
schema.properties.image_config.properties &&
schema.properties.image_config.properties.images) {
const imagesProp = schema.properties.image_config.properties.images;
if (imagesProp['x-widget'] === 'file-upload') {
return imagesProp['x-upload-config'] || {};
}
}
return {};
};
@@ -501,7 +455,7 @@
if (!imageList) return;
const uploadConfig = window.getUploadConfig(fieldId);
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
const pluginId = uploadConfig.plugin_id || 'static-image';
const openEditor = imageList.querySelector('[id^="schedule_"]:not(.hidden)');
const openScheduleId = openEditor ? openEditor.id.slice('schedule_'.length) : null;
@@ -20,9 +20,12 @@
* Load a plugin-specific widget
* @param {string} pluginId - Plugin ID
* @param {string} widgetName - Widget name
* @param {string} [version] - Plugin version, appended as ?v= so an
* updated plugin's widget is not served from the year-long
* immutable cache /static/ responses get (app.py)
* @returns {Promise<void>} Promise that resolves when widget is loaded
*/
window.LEDMatrixWidgets.loadPluginWidget = async function(pluginId, widgetName) {
window.LEDMatrixWidgets.loadPluginWidget = async function(pluginId, widgetName, version) {
if (!pluginId || !widgetName) {
throw new Error('Plugin ID and widget name are required');
}
@@ -36,8 +39,9 @@
// The one route that serves plugin widgets (serve_plugin_widget in
// blueprints/pages_v3.py); nothing is served under /plugins/<id>/ or
// /static/plugins/<id>/, so trying those only added two failed imports.
const versionQuery = version ? `?v=${encodeURIComponent(version)}` : '';
const possiblePaths = [
`/static/plugin-widgets/${pluginId}/${widgetName}.js`
`/static/plugin-widgets/${pluginId}/${widgetName}.js${versionQuery}`
];
let lastError = null;
@@ -69,9 +73,10 @@
* Called automatically when a widget is referenced in a plugin's config schema
* @param {string} widgetName - Widget name
* @param {string} pluginId - Plugin ID (optional, for plugin-specific widgets)
* @param {string} [version] - Plugin version (optional, see loadPluginWidget)
* @returns {Promise<boolean>} True if widget is available (either already registered or successfully loaded)
*/
window.LEDMatrixWidgets.ensureWidget = async function(widgetName, pluginId) {
window.LEDMatrixWidgets.ensureWidget = async function(widgetName, pluginId, version) {
// Check if widget is already registered
if (this.has(widgetName)) {
return true;
@@ -80,7 +85,7 @@
// If plugin ID provided, try to load as plugin widget
if (pluginId) {
try {
await this.loadPluginWidget(pluginId, widgetName);
await this.loadPluginWidget(pluginId, widgetName, version);
return this.has(widgetName);
} catch (error) {
console.warn(`[PluginWidgetLoader] Could not load widget ${widgetName} from plugin ${pluginId}:`, error);
@@ -91,37 +96,4 @@
// Widget not found
return false;
};
/**
* Load all widgets specified in plugin manifest
* @param {string} pluginId - Plugin ID
* @param {Object} manifest - Plugin manifest object
* @returns {Promise<Array<string>>} Array of successfully loaded widget names
*/
window.LEDMatrixWidgets.loadPluginWidgetsFromManifest = async function(pluginId, manifest) {
if (!manifest || !manifest.widgets || !Array.isArray(manifest.widgets)) {
return [];
}
const loadedWidgets = [];
for (const widgetDef of manifest.widgets) {
const widgetName = widgetDef.name || widgetDef.script?.replace(/\.js$/, '');
if (!widgetName) {
console.warn(`[PluginWidgetLoader] Invalid widget definition in manifest:`, widgetDef);
continue;
}
try {
await this.loadPluginWidget(pluginId, widgetName);
if (this.has(widgetName)) {
loadedWidgets.push(widgetName);
}
} catch (error) {
console.error(`[PluginWidgetLoader] Failed to load widget ${widgetName} from plugin ${pluginId}:`, error);
}
}
return loadedWidgets;
};
})();