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
+3 -25
View File
@@ -299,8 +299,6 @@ html[data-theme="dark"] {
.gap-x-2 { column-gap: 0.5rem; }
.gap-x-3 { column-gap: 0.75rem; }
.gap-x-4 { column-gap: 1rem; }
.gap-x-6 { column-gap: 1.5rem; }
.gap-x-8 { column-gap: 2rem; }
.gap-y-2 { row-gap: 0.5rem; }
/* Enhanced Typography */
@@ -329,7 +327,6 @@ h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
.border-b { border-bottom-width: 1px; }
.border-b-2 { border-bottom-width: 2px; }
.relative { position: relative; }
.fixed { position: fixed; }
@@ -339,14 +336,11 @@ h4 { font-size: 1.125rem; }
.top-4 { top: 1rem; }
.right-4 { right: 1rem; }
.max-w-7xl { max-width: 56rem; }
.max-w-full { max-width: 100%; }
.mx-auto { margin-left: auto; margin-right: auto; }
.overflow-x-auto { overflow-x: auto; }
.overflow-hidden { overflow: hidden; }
.aspect-video { aspect-ratio: 16 / 9; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.transition { transition-property: transform, opacity, color, border-color; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
@@ -530,12 +524,10 @@ a, button, input, select, textarea {
.border-gray-400 { border-color: #9ca3af; }
.border-gray-600 { border-color: #4b5563; }
.border-green-200 { border-color: #bbf7d0; }
.border-green-300 { border-color: #86efac; }
.border-green-400 { border-color: #4ade80; }
.border-green-500 { border-color: #22c55e; }
.border-l-0 { border-left-width: 0px; border-left-style: solid; }
.border-purple-200 { border-color: #e9d5ff; }
.border-purple-300 { border-color: #d8b4fe; }
.border-r-0 { border-right-width: 0px; border-right-style: solid; }
.border-red-200 { border-color: #fecaca; }
.border-red-300 { border-color: #fca5a5; }
@@ -544,7 +536,6 @@ a, button, input, select, textarea {
.border-t { border-top-width: 1px; border-top-style: solid; }
.border-yellow-200 { border-color: #fef08a; }
.border-yellow-300 { border-color: #fde047; }
.border-yellow-400 { border-color: #facc15; }
.break-all { word-break: break-all; }
.break-words { overflow-wrap: break-word; }
.capitalize { text-transform: capitalize; }
@@ -570,7 +561,6 @@ a, button, input, select, textarea {
.h-12 { height: 3rem; }
.h-20 { height: 5rem; }
.h-3 { height: 0.75rem; }
.h-40 { height: 10rem; }
.h-5 { height: 1.25rem; }
.h-6 { height: 1.5rem; }
.h-64 { height: 16rem; }
@@ -642,7 +632,6 @@ a, button, input, select, textarea {
.pointer-events-auto { pointer-events: auto; }
.pointer-events-none { pointer-events: none; }
.pr-10 { padding-right: 2.5rem; }
.pr-2 { padding-right: 0.5rem; }
.pr-20 { padding-right: 5rem; }
.pr-3 { padding-right: 0.75rem; }
.pr-4 { padding-right: 1rem; }
@@ -666,7 +655,6 @@ a, button, input, select, textarea {
.ring-0 { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 var(--tw-ring-offset-width, 0px) var(--tw-ring-color, transparent); }
.rounded-b-lg { border-bottom-left-radius: 0.5rem; border-bottom-right-radius: 0.5rem; }
.rounded-l-md { border-top-left-radius: 0.375rem; border-bottom-left-radius: 0.375rem; }
.rounded-r-lg { border-top-right-radius: 0.5rem; border-bottom-right-radius: 0.5rem; }
.rounded-r-md { border-top-right-radius: 0.375rem; border-bottom-right-radius: 0.375rem; }
.rounded-t-lg { border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; }
.self-center { align-self: center; }
@@ -819,7 +807,6 @@ a, button, input, select, textarea {
.hover\:text-yellow-900:hover { color: #713f12; }
.hover\:underline:hover { text-decoration-line: underline; text-underline-offset: 0.15em; }
.last\:border-0:last-child { border-width: 0px; }
.peer:checked ~ .peer-checked\:bg-green-500 { --tw-bg-opacity: 1; background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1)); }
.peer:focus-visible ~ .peer-focus\:outline-none { outline: 2px solid transparent; outline-offset: 2px; }
.peer:focus-visible ~ .peer-focus\:ring-4 { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 calc(4px + var(--tw-ring-offset-width, 0px)) var(--tw-ring-color, rgb(59 130 246 / 50%)), var(--tw-shadow, 0 0 #0000); }
.peer:focus-visible ~ .peer-focus\:ring-blue-300 { --tw-ring-color: #93c5fd; }
@@ -1246,30 +1233,23 @@ button.bg-white {
}
/* Filter Pill Toggle States */
.filter-pill,
.category-filter-pill {
.filter-pill {
cursor: pointer;
user-select: none;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
}
.filter-pill[data-active="true"],
.category-filter-pill[data-active="true"] {
.filter-pill[data-active="true"] {
background-color: var(--color-info-bg);
border-color: var(--color-info);
color: var(--color-info);
font-weight: 600;
}
.filter-pill[data-active="true"]:hover,
.category-filter-pill[data-active="true"]:hover {
.filter-pill[data-active="true"]:hover {
opacity: 0.85;
}
.category-filter-pill[data-active="true"] {
box-shadow: 0 0 0 1px var(--color-info);
}
/* Section Headers with Subtle Gradients */
.section-header {
background: linear-gradient(135deg, rgb(255 255 255 / 90%) 0%, rgb(249 250 251 / 90%) 100%);
@@ -1707,9 +1687,7 @@ button.bg-white {
[data-theme="dark"] .border-blue-300 { border-color: rgb(59 130 246 / 55%); }
[data-theme="dark"] .border-gray-100 { border-color: var(--color-border); }
[data-theme="dark"] .border-gray-400 { border-color: #6b7280; }
[data-theme="dark"] .border-green-300 { border-color: rgb(34 197 94 / 55%); }
[data-theme="dark"] .border-purple-200 { border-color: rgb(168 85 247 / 40%); }
[data-theme="dark"] .border-purple-300 { border-color: rgb(168 85 247 / 55%); }
[data-theme="dark"] .border-red-300 { border-color: rgb(239 68 68 / 55%); }
[data-theme="dark"] .border-yellow-300 { border-color: rgb(234 179 8 / 55%); }
[data-theme="dark"] .divide-gray-100 > :not(:first-child) { border-color: var(--color-border); }
+1 -6
View File
@@ -651,12 +651,7 @@ function markPanelLoadFailed(event) {
// Also update window.installedPlugins for consistency
window.installedPlugins = this.installedPlugins;
debugLog(`[FULL] Loaded ${this.installedPlugins.length} plugins:`, this.installedPlugins.map(p => p.id));
// Debug: Log enabled status for each plugin
this.installedPlugins.forEach(plugin => {
debugLog(`[DEBUG Alpine] Plugin ${plugin.id}: enabled=${plugin.enabled} (type: ${typeof plugin.enabled})`);
});
this.updatePluginTabs();
} else {
console.error('[FULL] Failed to load plugins:', data.message);
+4 -9
View File
@@ -12,44 +12,39 @@
if (!target) {
console.warn('[HTMX] Target is null, skipping swap');
event.detail.shouldSwap = false;
return false;
return;
}
// Check if target is a valid DOM element
if (!(target instanceof Element)) {
console.warn('[HTMX] Target is not a valid Element, skipping swap');
event.detail.shouldSwap = false;
return false;
return;
}
// Check if target has a parent node (required for insertBefore)
if (!target.parentNode) {
console.warn('[HTMX] Target has no parent node, skipping swap');
event.detail.shouldSwap = false;
return false;
return;
}
// Ensure target is in the DOM
if (!document.body.contains(target) && !document.head.contains(target)) {
console.warn('[HTMX] Target is not in DOM, skipping swap');
event.detail.shouldSwap = false;
return false;
return;
}
// Additional check: ensure parent is also in DOM
if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) {
console.warn('[HTMX] Target parent is not in DOM, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// All checks passed, allow swap
return true;
} catch (e) {
// If validation fails, cancel swap
console.warn('[HTMX] Error validating target:', e);
event.detail.shouldSwap = false;
return false;
}
});
@@ -31,8 +31,6 @@ const RequestThrottler = {
debugLog('[RequestThrottler] Creating new request for:', key);
// Create throttled request with abort support
let abortController = null;
const promise = new Promise((resolve, reject) => {
setTimeout(async () => {
try {
@@ -54,22 +52,8 @@ const RequestThrottler = {
this.pending.delete(key);
}
}, delay);
// Store abort controller if available
if (fn.abort && typeof fn.abort === 'function') {
abortController = fn.abort;
}
});
// Add abort method if available
if (abortController) {
promise.abort = () => {
debugLog('[RequestThrottler] Aborting request for:', key);
abortController.abort();
this.pending.delete(key);
};
}
this.pending.set(key, promise);
return promise;
},
@@ -83,18 +67,6 @@ const RequestThrottler = {
} else {
this.cache.clear();
}
},
/**
* Get statistics about pending requests and cache
*/
getStats() {
return {
pendingCount: this.pending.size,
cacheSize: this.cache.size,
pendingKeys: Array.from(this.pending.keys()),
cacheKeys: Array.from(this.cache.keys())
};
}
};
@@ -225,18 +197,6 @@ const PluginAPI = {
}
},
/**
* Batch multiple requests together for better performance
*
* @param {Array} requests - Array of {endpoint, method, data} objects
* @returns {Promise<Array>} Array of response data
*/
async batch(requests) {
return Promise.all(requests.map(req =>
this.request(req.endpoint, req.method || 'GET', req.data || null, false)
));
},
/**
* Clear API cache
*/
@@ -308,34 +268,6 @@ const PluginAPI = {
return await this.request(`/plugins/config/reset?plugin_id=${encodeURIComponent(pluginId)}`, 'POST');
},
/**
* Get plugin schema.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Plugin schema
*/
async getPluginSchema(pluginId) {
const response = await this.request(`/plugins/schema?plugin_id=${encodeURIComponent(pluginId)}`);
return response.data?.schema || null;
},
/**
* Install plugin from store.
*
* @param {string} pluginId - Plugin identifier
* @param {string} branch - Optional branch name to install from
* @returns {Promise<Object>} Response data
*/
async installPlugin(pluginId, branch = null) {
const data = {
plugin_id: pluginId
};
if (branch) {
data.branch = branch;
}
return await this.request('/plugins/install', 'POST', data);
},
/**
* Update plugin.
*
@@ -348,28 +280,6 @@ const PluginAPI = {
});
},
/**
* Uninstall plugin.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Response data
*/
async uninstallPlugin(pluginId) {
return await this.request('/plugins/uninstall', 'POST', {
plugin_id: pluginId
});
},
/**
* Get plugin store.
*
* @returns {Promise<Array>} List of available plugins
*/
async getPluginStore() {
const response = await this.request('/plugins/store/list');
return response.data || [];
},
/**
* Get plugin health.
*
@@ -1,83 +1,11 @@
/**
* Plugin installation and update management.
*
* Handles plugin installation, updates, and uninstallation operations.
* Handles Check & Update All: which installed plugins it sends, retrying
* across a web-service restart, and summarising the results.
*/
const PluginInstallManager = {
/**
* Install a plugin.
*
* @param {string} pluginId - Plugin identifier
* @param {string} branch - Optional branch name to install from
* @returns {Promise<Object>} Installation result
*/
async install(pluginId, branch = null) {
try {
const result = await window.PluginAPI.installPlugin(pluginId, branch);
// Refresh installed plugins list
if (window.PluginStateManager) {
await window.PluginStateManager.loadInstalledPlugins();
}
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to install plugin ${pluginId}`);
}
throw error;
}
},
/**
* Update a plugin.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Update result
*/
async update(pluginId) {
try {
const result = await window.PluginAPI.updatePlugin(pluginId);
// Refresh installed plugins list
if (window.PluginStateManager) {
await window.PluginStateManager.loadInstalledPlugins();
}
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to update plugin ${pluginId}`);
}
throw error;
}
},
/**
* Uninstall a plugin.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Uninstall result
*/
async uninstall(pluginId) {
try {
const result = await window.PluginAPI.uninstallPlugin(pluginId);
// Refresh installed plugins list
if (window.PluginStateManager) {
await window.PluginStateManager.loadInstalledPlugins();
}
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to uninstall plugin ${pluginId}`);
}
throw error;
}
},
/**
* Whether POST /plugins/update can update this installed-list entry.
*
@@ -9,12 +9,7 @@ const PluginStateManager = {
* Installed plugins state.
*/
installedPlugins: [],
/**
* Current plugin configuration state.
*/
currentConfig: null,
/**
* Load installed plugins.
*
@@ -32,83 +27,6 @@ const PluginStateManager = {
}
throw error;
}
},
/**
* Get plugin by ID.
*
* @param {string} pluginId - Plugin identifier
* @returns {Object|null} Plugin object or null
*/
getPlugin(pluginId) {
return this.installedPlugins.find(p => p.id === pluginId) || null;
},
/**
* Update plugin state.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} updates - State updates
*/
updatePlugin(pluginId, updates) {
const plugin = this.getPlugin(pluginId);
if (plugin) {
Object.assign(plugin, updates);
}
},
/**
* Set plugin enabled state.
*
* @param {string} pluginId - Plugin identifier
* @param {boolean} enabled - Whether plugin is enabled
*/
setPluginEnabled(pluginId, enabled) {
this.updatePlugin(pluginId, { enabled });
},
/**
* Get current plugin configuration.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Plugin configuration
*/
async getPluginConfig(pluginId) {
try {
const config = await window.PluginAPI.getPluginConfig(pluginId);
this.currentConfig = { pluginId, config };
return config;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to load config for ${pluginId}`);
}
throw error;
}
},
/**
* Save plugin configuration.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} config - Configuration data
* @returns {Promise<Object>} Save result
*/
async savePluginConfig(pluginId, config) {
try {
const result = await window.PluginAPI.savePluginConfig(pluginId, config);
// Update local state
if (this.currentConfig && this.currentConfig.pluginId === pluginId) {
this.currentConfig.config = config;
}
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to save config for ${pluginId}`);
}
throw error;
}
}
};
@@ -176,19 +176,19 @@ function showErrorModal(error, context, message, suggestions, docLink) {
}
// Build modal content
const contextText = context ? `<div class="text-sm text-gray-600 mb-2">${escapeHtml(context)}</div>` : '';
const contextText = context ? `<div class="text-sm text-gray-600 mb-2">${window.LEDEscape.html(context)}</div>` : '';
const suggestionsHtml = suggestions.length > 0 ? `
<div class="mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-2">Suggested fixes:</h4>
<ul class="list-disc list-inside space-y-1 text-sm text-gray-700">
${suggestions.map(s => `<li>${escapeHtml(s)}</li>`).join('')}
${suggestions.map(s => `<li>${window.LEDEscape.html(s)}</li>`).join('')}
</ul>
</div>
` : '';
const docLinkHtml = docLink ? `
<div class="mt-4">
<a href="${escapeHtml(docLink)}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 text-sm underline">
<a href="${window.LEDEscape.html(docLink)}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 text-sm underline">
<i class="fas fa-book mr-1" aria-hidden="true"></i>View troubleshooting guide<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;"> (opens in a new tab)</span>
</a>
</div>
@@ -198,14 +198,14 @@ function showErrorModal(error, context, message, suggestions, docLink) {
<div class="mt-4">
<details class="cursor-pointer">
<summary class="text-sm font-medium text-gray-700 hover:text-gray-900">Technical details</summary>
<pre class="mt-2 text-xs bg-gray-100 p-3 rounded overflow-auto max-h-48 text-gray-800">${escapeHtml(error.details)}</pre>
<pre class="mt-2 text-xs bg-gray-100 p-3 rounded overflow-auto max-h-48 text-gray-800">${window.LEDEscape.html(error.details)}</pre>
</details>
</div>
` : '';
const errorCodeHtml = error.error_code ? `
<div class="mt-2 text-xs text-gray-500">
Error code: <code class="bg-gray-100 px-1 py-0.5 rounded">${escapeHtml(error.error_code)}</code>
Error code: <code class="bg-gray-100 px-1 py-0.5 rounded">${window.LEDEscape.html(error.error_code)}</code>
</div>
` : '';
@@ -223,7 +223,7 @@ function showErrorModal(error, context, message, suggestions, docLink) {
<h3 id="error-modal-title" class="text-lg leading-6 font-medium text-gray-900">Something went wrong</h3>
<div class="mt-2" id="error-modal-description">
${contextText}
<p class="text-sm text-gray-500">${escapeHtml(message)}</p>
<p class="text-sm text-gray-500">${window.LEDEscape.html(message)}</p>
${errorCodeHtml}
${suggestionsHtml}
${docLinkHtml}
@@ -284,8 +284,6 @@ function closeErrorModal() {
}
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
/**
* Copy error details to clipboard.
*
@@ -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;
};
})();
+26 -156
View File
@@ -45,7 +45,7 @@
* installFromCustomRegistry, removeSavedRepository, executePluginAction,
* openOnDemandModal, requestOnDemandStop, loadOnDemandStatus,
* installStarlarkApp, installPixlet, the GitHub token functions,
* window.installedPlugins and window.currentPluginConfig.
* and window.installedPlugins.
*/
// ─── LocalStorage Safety Wrappers ────────────────────────────────────────────
@@ -215,7 +215,7 @@ window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
if (plugin) {
plugin.enabled = !enabled;
}
window.pluginManager.loadInstalledPlugins();
window.pluginManager.loadInstalledPlugins().catch(() => {});
}
// Clear token and re-enable UI
@@ -243,7 +243,7 @@ window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
if (plugin) {
plugin.enabled = !enabled;
}
window.pluginManager.loadInstalledPlugins();
window.pluginManager.loadInstalledPlugins().catch(() => {});
// Clear token and re-enable UI
delete window._pluginToggleRequests[pluginId];
@@ -452,10 +452,9 @@ window.handleGitHubPluginInstall = function() {
}
urlInput.value = '';
// Show notification if available
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
setTimeout(() => window.pluginManager.loadInstalledPlugins(true), 1000);
setTimeout(() => window.pluginManager.loadInstalledPlugins(true).catch(() => {}), 1000);
} else {
if (statusDiv) {
statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${window.LEDEscape.html(data.message || 'Installation failed')}</span>`;
@@ -609,7 +608,6 @@ window.checkGitHubAuthStatus = function checkGitHubAuthStatus() {
// Local variables for this instance
let installedPlugins = [];
window.currentPluginConfig = null;
let pluginStoreCache = null; // Cache for plugin store to speed up subsequent loads
let cacheTimestamp = null;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
@@ -904,6 +902,9 @@ const pluginLoadCache = {
}
};
// Rejects after it has reported the failure itself (panel error or toast),
// so fire-and-forget callers add .catch(() => {}) rather than leave it to
// the global unhandledrejection handler, which would toast it again.
function loadInstalledPlugins(forceRefresh = false) {
// Return cached data if valid and not forcing refresh
if (!forceRefresh && pluginLoadCache.isValid()) {
@@ -1381,7 +1382,7 @@ function handlePluginAction(event) {
.then(data => {
if (data.status === 'success') {
showNotification('Starlark app uninstalled', 'success');
loadInstalledPlugins(true);
loadInstalledPlugins(true).catch(() => {});
} else {
alert('Uninstall failed: ' + (data.message || 'Unknown error'));
}
@@ -1544,19 +1545,8 @@ function runUpdateAllPlugins() {
return;
}
// Counted by install_manager.js from each answer's update_status:
// a no-op update is "already up to date", not "updated". A cached
// install_manager.js from before that helper gets a plain count.
const manager = window.PluginInstallManager;
const summary = (manager && typeof manager.summarizeUpdateResults === 'function')
? manager.summarizeUpdateResults(results)
: (() => {
const failed = results.filter(r => !r.success).length;
const checked = results.length - failed;
return {
text: `${checked} checked` + (failed ? `, ${failed} failed` : ''),
type: failed ? (checked ? 'warning' : 'error') : 'success'
};
})();
// a no-op update is "already up to date", not "updated".
const summary = window.PluginInstallManager.summarizeUpdateResults(results);
showNotification(summary.text, summary.type);
})
.catch(error => {
@@ -1887,158 +1877,38 @@ function closeOnDemandModalOnBackdrop(event) {
}
// Generic Plugin Action Handler
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) {
debugLog('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam);
// Construct button ID first (we have actionId and actionIndex)
window.executePluginAction = function(actionId, actionIndex, pluginId) {
const actionIdFull = `action-${actionId}-${actionIndex}`;
const statusId = `action-status-${actionId}-${actionIndex}`;
const btn = document.getElementById(actionIdFull);
const statusDiv = document.getElementById(statusId);
// Get plugin ID from multiple sources with comprehensive fallback logic
let pluginId = pluginIdParam;
// Fallback 1: Try to get from button's data-plugin-id attribute
if (!pluginId && btn) {
pluginId = btn.getAttribute('data-plugin-id');
if (pluginId) {
debugLog('[DEBUG] Got pluginId from button data attribute:', pluginId);
}
}
// Fallback 2: Try to get from closest parent with data-plugin-id
if (!pluginId && btn) {
const parentWithPluginId = btn.closest('[data-plugin-id]');
if (parentWithPluginId) {
pluginId = parentWithPluginId.getAttribute('data-plugin-id');
if (pluginId) {
debugLog('[DEBUG] Got pluginId from parent element:', pluginId);
}
}
}
// Fallback 3: Try to get from plugin-config-container or plugin-config-tab
if (!pluginId && btn) {
const container = btn.closest('.plugin-config-container, .plugin-config-tab, [id^="plugin-config-"]');
if (container) {
// Try data-plugin-id first
pluginId = container.getAttribute('data-plugin-id');
if (!pluginId) {
// Try to extract from ID like "plugin-config-{pluginId}"
const idMatch = container.id.match(/plugin-config-(.+)/);
if (idMatch) {
pluginId = idMatch[1];
}
}
if (pluginId) {
debugLog('[DEBUG] Got pluginId from container:', pluginId);
}
}
}
// Fallback 4: Try to get from currentPluginConfig
// The action button (plugin_config.html) always passes its plugin's id.
if (!pluginId) {
pluginId = currentPluginConfig?.pluginId;
if (pluginId) {
debugLog('[DEBUG] Got pluginId from currentPluginConfig:', pluginId);
}
}
// Fallback 5: the active tab, when it is a plugin's tab
if (!pluginId) {
const appData = window.getApp();
if (appData && appData.activeTab && appData.activeTab !== 'overview' && appData.activeTab !== 'plugins' && appData.activeTab !== 'wifi') {
pluginId = appData.activeTab;
debugLog('[DEBUG] Got pluginId from Alpine activeTab:', pluginId);
}
}
// Fallback 6: Try to find from plugin tab elements (scoped to button context)
if (!pluginId && btn) {
try {
// Search within the button's Alpine.js context (closest x-data element)
const buttonContext = btn.closest('[x-data]');
if (buttonContext) {
const pluginTab = buttonContext.querySelector('[x-show*="activeTab === plugin.id"]');
if (pluginTab && window.Alpine) {
try {
const pluginData = Alpine.$data(buttonContext);
if (pluginData && pluginData.plugin) {
pluginId = pluginData.plugin.id;
if (pluginId) {
debugLog('[DEBUG] Got pluginId from Alpine plugin data (scoped to button context):', pluginId);
}
}
} catch (e) {
console.warn('[DEBUG] Error accessing Alpine plugin data:', e);
}
}
}
// If not found in button context, try container element
if (!pluginId) {
const container = btn.closest('.plugin-config-container, .plugin-config-tab, [id^="plugin-config-"]');
if (container) {
const containerContext = container.querySelector('[x-show*="activeTab === plugin.id"]');
if (containerContext && window.Alpine) {
try {
const containerData = Alpine.$data(container.closest('[x-data]'));
if (containerData && containerData.plugin) {
pluginId = containerData.plugin.id;
if (pluginId) {
debugLog('[DEBUG] Got pluginId from Alpine plugin data (scoped to container):', pluginId);
}
}
} catch (e) {
console.warn('[DEBUG] Error accessing Alpine plugin data from container:', e);
}
}
}
}
} catch (e) {
console.warn('[DEBUG] Error in fallback 6 DOM lookup:', e);
}
}
// Final check - if still no pluginId, show error
if (!pluginId) {
console.error('No plugin ID available after all fallbacks. actionId:', actionId, 'actionIndex:', actionIndex);
console.error('[DEBUG] Button found:', !!btn);
console.error('[DEBUG] currentPluginConfig:', currentPluginConfig);
console.error('executePluginAction called without a plugin ID. actionId:', actionId, 'actionIndex:', actionIndex);
showNotification('Unable to determine plugin ID. Please refresh the page.', 'error');
return;
}
debugLog('[DEBUG] executePluginAction - Final pluginId:', pluginId, 'actionId:', actionId, 'actionIndex:', actionIndex);
if (!btn || !statusDiv) {
console.error(`Action elements not found: ${actionIdFull}`);
return;
}
// Get action definition - try currentPluginConfig first, then fetch from API
let action = currentPluginConfig?.webUiActions?.[actionIndex];
if (!action) {
// Try to get from installed plugins
if (window.installedPlugins) {
const plugin = window.installedPlugins.find(p => p.id === pluginId);
if (plugin && plugin.web_ui_actions) {
action = plugin.web_ui_actions[actionIndex];
}
let action = null;
if (window.installedPlugins) {
const plugin = window.installedPlugins.find(p => p.id === pluginId);
if (plugin && plugin.web_ui_actions) {
action = plugin.web_ui_actions[actionIndex];
}
}
if (!action) {
console.error(`Action not found: ${actionId} for plugin ${pluginId}`);
debugLog('[DEBUG] currentPluginConfig:', currentPluginConfig);
debugLog('[DEBUG] installedPlugins:', window.installedPlugins);
showNotification(`Action ${actionId} not found. Please refresh the page.`, 'error');
return;
}
debugLog('[DEBUG] Found action:', action);
// Check if we're in step 2 (completing OAuth flow)
if (btn.dataset.step === '2') {
const redirectUrl = prompt(action.step2_prompt || 'Please paste the full redirect URL:');
@@ -2210,11 +2080,11 @@ function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
const onComplete = options.onComplete || (() => handleUninstallSuccess(pluginId));
const onFailed = options.onFailed || ((errorMsg) => {
showNotification(errorMsg || `Operation failed for ${pluginName}`, 'error');
setTimeout(() => loadInstalledPlugins(), 1000);
setTimeout(() => loadInstalledPlugins().catch(() => {}), 1000);
});
const onTimeout = options.onTimeout || (() => {
showNotification(`Operation timed out for ${pluginName}`, 'error');
setTimeout(() => loadInstalledPlugins(), 1000);
setTimeout(() => loadInstalledPlugins().catch(() => {}), 1000);
});
if (attempt >= maxAttempts) {
@@ -2250,7 +2120,7 @@ function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
console.error('Error polling operation status:', error);
// On error, refresh plugin list to see actual state
setTimeout(() => {
loadInstalledPlugins();
loadInstalledPlugins().catch(() => {});
}, 1000);
});
}
@@ -2268,7 +2138,7 @@ function handleUninstallSuccess(pluginId) {
// refreshInstalledPlugins() bypasses them.
pluginLoadCache.invalidate();
setTimeout(() => {
refreshInstalledPlugins();
refreshInstalledPlugins().catch(() => {});
}, 1000);
}
@@ -2284,7 +2154,7 @@ function refreshPlugins() {
refreshInstalledPlugins().then(() => {
searchPluginStore(true);
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
});
}, () => {});
}
function restartDisplay() {
@@ -2637,7 +2507,7 @@ window.installPlugin = function(pluginId, branch = null) {
}
});
// Refresh installed plugins list, then re-render store to update badges
loadInstalledPlugins();
loadInstalledPlugins().catch(() => {});
setTimeout(() => applyStoreFiltersAndSort(true), 500);
}
@@ -2696,7 +2566,7 @@ window.installFromCustomRegistry = function(pluginId, registryUrl, pluginPath, b
if (data.status === 'success') {
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
// Refresh installed plugins and re-render custom registry
loadInstalledPlugins();
loadInstalledPlugins().catch(() => {});
// Re-render custom registry to update install buttons
const registryUrlInput = document.getElementById('github-registry-url');
if (registryUrlInput && registryUrlInput.value.trim()) {
@@ -2890,7 +2760,7 @@ function attachInstallButtonHandler() {
// Refresh installed plugins list
setTimeout(() => {
loadInstalledPlugins();
loadInstalledPlugins().catch(() => {});
}, 1000);
} else {
if (pluginStatusDiv) {