fix(web): seven web UI bugs, and remove dead plugins_manager.js helpers (#647)

- Operation History: the plugin filter lists the installed plugin ids
  instead of one option, "plugins" (Object.keys of {plugins: [...]}).
- Ctrl/Cmd+S submits the active tab's first visible form with
  requestSubmit() (validation and onsubmit guards run) instead of a bare
  Event on the first form in the document; skipped inside a modal dialog
  and on tabs without a form.
- Overview "Check Updates" confirms like "Update Code", takes its button
  explicitly (no implicit global event) and shows the server's message.
  Both, and the Tools tab git pull, raise the restart-pending banner on
  restart_required.
- Tools: toolsAction and diagnostics show the server's error message;
  only a non-JSON body falls back to HTTP <status>.
- Installed list after uninstall: PluginAPI writes clear the throttler's
  GET cache, a forced loadInstalledPlugins clears it too, and the
  post-uninstall reload goes through refreshInstalledPlugins().
- Plugin widgets load from /static/plugin-widgets/ only (the other two
  paths have no route).
- Raw JSON editor escapes the parse error; slider escapes value/min/max/step.
- Removed the unreferenced array-of-objects and key-value helpers from
  plugins_manager.js, the textarea auto-resize and Ctrl+R handlers in
  app.js, and a redundant ?v= on the plugins_manager.js script tag.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 08:26:27 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7eb7a58d0c
commit 76f5d8a336
11 changed files with 103 additions and 697 deletions
+9
View File
@@ -37,6 +37,15 @@ accepts both, but the store flags the old spelling as deprecated
- `check_system_compatibility.sh` no longer reports installed packages as missing. - `check_system_compatibility.sh` no longer reports installed packages as missing.
- A network failure fetching GitHub repo info logs a warning, not an error. - A network failure fetching GitHub repo info logs a warning, not an error.
- Web UI fixes:
- The Operation History plugin filter lists installed plugins (it showed one option, "plugins").
- Ctrl/Cmd+S submits the active tab's visible form (with its validation) instead of the first form in the page; it does nothing inside a dialog or on a tab without a form. The Ctrl/Cmd+R override (the browser's own reload) and the textarea auto-resize (no textarea exists at load) are removed.
- Overview "Check Updates" asks for the same confirmation as "Update Code" and shows the server's message. Both, and the Tools tab's git pull, show the restart-pending banner when the update needs a restart.
- Tools tab actions and diagnostics show the server's error message; only a non-JSON error falls back to `HTTP <status>`.
- An uninstalled plugin no longer reappears in the installed list: writes through `PluginAPI` clear its 5s GET cache, and Refresh and the post-uninstall reload bypass both list caches.
- Plugin widgets load from `/static/plugin-widgets/` only; the two other paths it tried have no route.
- The raw JSON editor escapes the parse error, and the slider widget escapes its value, min, max and step.
- Removed unused array-of-objects and key-value helpers from `plugins_manager.js` (about 640 lines, no callers) and a redundant `?v=` on its script tag.
- Web UI and `src.common` fixes: - Web UI and `src.common` fixes:
- A wrong Wi-Fi password is reported as one again ("Incorrect password for ..."); the fallback that restores the old network or brings up the setup AP was replacing the signal. - A wrong Wi-Fi password is reported as one again ("Incorrect password for ..."); the fallback that restores the old network or brings up the setup AP was replacing the signal.
- Plugin tabs show the manifest's `icon`: `/api/v3/plugins/installed` now includes it. - Plugin tabs show the manifest's `icon`: `/api/v3/plugins/installed` now includes it.
+22 -21
View File
@@ -229,31 +229,32 @@ document.addEventListener('DOMContentLoaded', function() {
// SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in // SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in
// js/app-shell.js — do not open EventSources for stats/display here. // js/app-shell.js — do not open EventSources for stats/display here.
// Auto-resize textareas
document.addEventListener('DOMContentLoaded', function() {
const textareas = document.querySelectorAll('textarea');
textareas.forEach(textarea => {
textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = this.scrollHeight + 'px';
});
});
});
// Keyboard shortcuts // Keyboard shortcuts
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
// Ctrl/Cmd + R to refresh // Ctrl/Cmd + S to save the form on the active tab: the first visible form
if ((e.ctrlKey || e.metaKey) && e.key === 'r') { // in that tab's panel (#<tab>-content, or #plugin-config-<id> for a plugin
e.preventDefault(); // tab). The panel is looked up by activeTab rather than by visibility
location.reload(); // alone because during the x-transition between tabs both are visible.
} // requestSubmit() runs validation and onsubmit guards like a real submit;
// with no visible form, do nothing. Inside a modal dialog the shortcut is
// Ctrl/Cmd + S to save current form // the dialog's (json-file-manager saves its file on Ctrl+S), so the tab's
// form is left alone.
if ((e.ctrlKey || e.metaKey) && e.key === 's') { if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); e.preventDefault();
const form = document.querySelector('form'); const active = document.activeElement;
if (form) { if (active && active.closest && active.closest('[aria-modal="true"]')) {
form.dispatchEvent(new Event('submit')); return;
}
const app = typeof window.getApp === 'function' ? window.getApp() : null;
const tab = app && app.activeTab;
const panel = (tab && (document.getElementById(tab + '-content') ||
document.getElementById('plugin-config-' + tab))) ||
document.getElementById('tab-content');
if (!panel) return;
const form = Array.from(panel.querySelectorAll('form'))
.find(f => f.getClientRects().length > 0);
if (form && typeof form.requestSubmit === 'function') {
form.requestSubmit();
} }
} }
}); });
@@ -212,8 +212,16 @@ const PluginAPI = {
// Use throttling for GET requests, immediate execution for POST/PUT/DELETE // Use throttling for GET requests, immediate execution for POST/PUT/DELETE
if (useThrottle && method === 'GET') { if (useThrottle && method === 'GET') {
return await RequestThrottler.throttle(requestKey, makeRequest, 100); return await RequestThrottler.throttle(requestKey, makeRequest, 100);
} else { } else if (method === 'GET') {
return await makeRequest(); return await makeRequest();
} else {
// A write (install, uninstall, toggle, config save) can change any
// cached GET, e.g. the installed list; drop the cache once it lands.
try {
return await makeRequest();
} finally {
RequestThrottler.clearCache();
}
} }
}, },
@@ -33,11 +33,11 @@
return; return;
} }
// Try multiple possible paths for plugin widgets // 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 possiblePaths = [ const possiblePaths = [
`/static/plugin-widgets/${pluginId}/${widgetName}.js`, `/static/plugin-widgets/${pluginId}/${widgetName}.js`
`/plugins/${pluginId}/widgets/${widgetName}.js`,
`/static/plugins/${pluginId}/widgets/${widgetName}.js`
]; ];
let lastError = null; let lastError = null;
+7 -7
View File
@@ -85,7 +85,7 @@
html += ` html += `
<div class="flex justify-center mb-2"> <div class="flex justify-center mb-2">
<span id="${fieldId}_value" class="text-lg font-semibold text-gray-700"> <span id="${fieldId}_value" class="text-lg font-semibold text-gray-700">
${escapeHtml(prefix)}${currentValue}${escapeHtml(suffix)} ${escapeHtml(prefix)}${escapeHtml(currentValue)}${escapeHtml(suffix)}
</span> </span>
</div> </div>
`; `;
@@ -96,10 +96,10 @@
<input type="range" <input type="range"
id="${fieldId}_input" id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}" name="${escapeHtml(options.name || fieldId)}"
value="${currentValue}" value="${escapeAttr(currentValue)}"
min="${min}" min="${escapeAttr(min)}"
max="${max}" max="${escapeAttr(max)}"
step="${step}" step="${escapeAttr(step)}"
${disabled ? 'disabled' : ''} ${disabled ? 'disabled' : ''}
oninput="window.LEDMatrixWidgets.getHandlers('slider').onInput('${fieldId}')" oninput="window.LEDMatrixWidgets.getHandlers('slider').onInput('${fieldId}')"
onchange="window.LEDMatrixWidgets.getHandlers('slider').onChange('${fieldId}')" onchange="window.LEDMatrixWidgets.getHandlers('slider').onChange('${fieldId}')"
@@ -110,8 +110,8 @@
if (showMinMax) { if (showMinMax) {
html += ` html += `
<div class="flex justify-between mt-1"> <div class="flex justify-between mt-1">
<span class="text-xs text-gray-400">${escapeHtml(prefix)}${min}${escapeHtml(suffix)}</span> <span class="text-xs text-gray-400">${escapeHtml(prefix)}${escapeHtml(min)}${escapeHtml(suffix)}</span>
<span class="text-xs text-gray-400">${escapeHtml(prefix)}${max}${escapeHtml(suffix)}</span> <span class="text-xs text-gray-400">${escapeHtml(prefix)}${escapeHtml(max)}${escapeHtml(suffix)}</span>
</div> </div>
`; `;
} }
+12 -648
View File
@@ -33,7 +33,7 @@
* togglePlugin, the GitHub token helpers, handleGitHubPluginInstall, * togglePlugin, the GitHub token helpers, handleGitHubPluginInstall,
* checkGitHubAuthStatus); then the plugin-manager IIFE (private state: * checkGitHubAuthStatus); then the plugin-manager IIFE (private state:
* installedPlugins, the store cache, the on-demand poller); then the * installedPlugins, the store cache, the on-demand poller); then the
* array-of-objects field helpers; then the Starlark IIFE. * Starlark IIFE.
* *
* The installed list is published only by renderInstalledPlugins: it sets * The installed list is published only by renderInstalledPlugins: it sets
* window.installedPlugins and dispatches one `pluginsUpdated` event, which * window.installedPlugins and dispatches one `pluginsUpdated` event, which
@@ -44,9 +44,6 @@
* configurePlugin, togglePlugin, uninstallPlugin, installPlugin, * configurePlugin, togglePlugin, uninstallPlugin, installPlugin,
* installFromCustomRegistry, removeSavedRepository, executePluginAction, * installFromCustomRegistry, removeSavedRepository, executePluginAction,
* openOnDemandModal, requestOnDemandStop, loadOnDemandStatus, * openOnDemandModal, requestOnDemandStop, loadOnDemandStatus,
* renderArrayObjectItem, addArrayObjectItem, removeArrayObjectItem,
* updateArrayObjectData, handleArrayObjectFileUpload, removeArrayObjectFile,
* removeKeyValuePair, updateKeyValuePairData, getSchemaProperty,
* installStarlarkApp, installPixlet, the GitHub token functions, * installStarlarkApp, installPixlet, the GitHub token functions,
* window.installedPlugins and window.currentPluginConfig. * window.installedPlugins and window.currentPluginConfig.
*/ */
@@ -923,6 +920,12 @@ function loadInstalledPlugins(forceRefresh = false) {
debugLog('[FETCH] Loading installed plugins...'); debugLog('[FETCH] Loading installed plugins...');
// A forced refresh must not be answered from PluginAPI's own GET cache
// (RequestThrottler, 5s) either, or it re-renders the list it replaces.
if (forceRefresh && window.PluginAPI && typeof window.PluginAPI.clearCache === 'function') {
window.PluginAPI.clearCache();
}
// Use PluginAPI if available, otherwise fall back to direct fetch // Use PluginAPI if available, otherwise fall back to direct fetch
const fetchPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ? const fetchPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ?
window.PluginAPI.getInstalledPlugins().then(plugins => { window.PluginAPI.getInstalledPlugins().then(plugins => {
@@ -1883,516 +1886,6 @@ function closeOnDemandModalOnBackdrop(event) {
} }
} }
// Helper function to get the full property object from schema
// Uses greedy longest-match to handle schema keys containing dots (e.g., "eng.1")
function getSchemaProperty(schema, path) {
if (!schema || !schema.properties) return null;
const parts = path.split('.');
let current = schema.properties;
let i = 0;
while (i < parts.length) {
let matched = false;
// Try progressively longer candidates, longest first
for (let j = parts.length; j > i; j--) {
const candidate = parts.slice(i, j).join('.');
if (current && current[candidate]) {
if (j === parts.length) {
// Consumed all remaining parts — done
return current[candidate];
}
if (current[candidate].properties) {
current = current[candidate].properties;
i = j;
matched = true;
break;
} else {
return null; // Can't navigate deeper
}
}
}
if (!matched) {
return null;
}
}
return null;
}
// "x-display": "hidden" (or an object of nothing but hidden children).
// Mirrors prop_is_hidden in plugin_config.html.
function isHiddenSchemaProp(schema) {
if (!schema || typeof schema !== 'object') return false;
if (schema['x-display'] === 'hidden') return true;
const children = schema.properties;
if (children && typeof children === 'object') {
const values = Object.values(children);
return values.length > 0 && values.every(isHiddenSchemaProp);
}
return false;
}
// Helper function to render a single item in an array of objects
function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) {
const item = itemValue || {};
const itemId = `${escapeAttribute(fieldId)}_item_${index}`;
// Store original item data in data attribute to preserve non-editable properties after reindexing
const itemDataJson = JSON.stringify(item);
const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson)));
let html = `<div id="${itemId}" class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${index}" data-item-data="${escapeAttribute(itemDataBase64)}">`;
// Render each property of the object
const propertyOrder = itemsSchema['x-propertyOrder'] || Object.keys(itemProperties);
propertyOrder.forEach(propKey => {
if (!itemProperties[propKey]) return;
// "x-display": "hidden": no control. The stored value survives through
// data-item-data, which updateArrayObjectData overlays edits onto.
if (isHiddenSchemaProp(itemProperties[propKey])) return;
const propSchema = itemProperties[propKey];
const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default;
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const propDescription = propSchema.description || '';
const propFullKey = `${fullKey}[${index}].${propKey}`;
html += `<div class="mb-3">`;
// Handle file-upload widget (for logo field)
if (propSchema['x-widget'] === 'file-upload') {
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
if (propDescription) {
html += `<p class="text-xs text-gray-500 mb-2">${escapeHtml(propDescription)}</p>`;
}
const uploadConfig = propSchema['x-upload-config'] || {};
// Derive pluginId strictly from uploadConfig or currentPluginConfig, no hard-coded fallback
const pluginId = uploadConfig.plugin_id || (typeof currentPluginConfig !== 'undefined' ? currentPluginConfig?.pluginId : null) || (typeof window.currentPluginConfig !== 'undefined' ? window.currentPluginConfig?.pluginId : null) || null;
const logoValue = propValue || {};
// Use base64 encoding for JSON in data attributes to safely handle all characters
const logoDataJson = logoValue && Object.keys(logoValue).length > 0 ? JSON.stringify(logoValue) : '';
const logoDataBase64 = logoDataJson ? btoa(unescape(encodeURIComponent(logoDataJson))) : '';
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp'];
const maxSizeMB = uploadConfig.max_size_mb || 5;
const pluginIdParam = pluginId ? `'${escapeAttribute(pluginId)}'` : 'null';
const uploadConfigJson = JSON.stringify({ allowed_types: allowedTypes, max_size_mb: maxSizeMB });
const uploadConfigBase64 = btoa(unescape(encodeURIComponent(uploadConfigJson)));
html += `
<div class="file-upload-widget-inline"${logoDataBase64 ? ` data-file-data="${escapeAttribute(logoDataBase64)}" data-prop-key="${escapeAttribute(propKey)}"` : ` data-prop-key="${escapeAttribute(propKey)}"`} data-upload-config="${escapeAttribute(uploadConfigBase64)}">
<input type="file"
id="${escapeAttribute(itemId)}_logo_file"
accept="${escapeAttribute(allowedTypes.join(','))}"
style="display: none;"
onchange="handleArrayObjectFileUpload(event, '${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}', ${pluginIdParam})">
<button type="button"
onclick="document.getElementById('${escapeAttribute(itemId)}_logo_file').click()"
class="px-3 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md transition-colors">
<i class="fas fa-upload mr-1"></i> Upload Logo
</button>
`;
if (logoValue.path) {
html += `
<div class="mt-2 flex items-center space-x-2 uploaded-image-container">
<img src="/${escapeAttribute(logoValue.path.replace(/^\/+/, ''))}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
<button type="button"
onclick="removeArrayObjectFile('${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}')"
class="text-red-600 hover:text-red-800">
<i class="fas fa-trash"></i> Remove
</button>
</div>
`;
}
html += `</div>`;
} else if (propSchema.type === 'boolean') {
// Boolean checkbox
html += `
<label class="flex items-center">
<input type="checkbox"
id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}"
data-prop-key="${escapeAttribute(propKey)}"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
${propValue ? 'checked' : ''}
onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')">
<span class="ml-2 text-sm text-gray-700">${escapeHtml(propLabel)}</span>
</label>
`;
} else {
// Regular text/string input
html += `
<label for="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}" class="block text-sm font-medium text-gray-700 mb-1">
${escapeHtml(propLabel)}
</label>
`;
if (propDescription) {
html += `<p class="text-xs text-gray-500 mb-1">${escapeHtml(propDescription)}</p>`;
}
const placeholder = propSchema.format === 'uri' ? 'https://example.com/feed' : '';
html += `
<input type="${propSchema.format === 'uri' ? 'url' : 'text'}"
id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}"
data-prop-key="${escapeAttribute(propKey)}"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-white text-black"
value="${escapeAttribute(propValue || '')}"
placeholder="${escapeAttribute(placeholder)}"
onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')">
`;
}
html += `</div>`;
});
// Use schema-driven label for remove button, fallback to generic "Remove item"
const removeLabel = itemsSchema['x-removeLabel'] || 'Remove item';
html += `
<button type="button"
onclick="removeArrayObjectItem('${escapeAttribute(fieldId)}', ${index})"
class="mt-2 px-3 py-2 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
<i class="fas fa-trash mr-1"></i> ${escapeHtml(removeLabel)}
</button>
</div>`;
return html;
}
// Functions to handle patternProperties key-value pairs
window.removeKeyValuePair = function(fieldId, index) {
const pairsContainer = document.getElementById(fieldId + '_pairs');
if (!pairsContainer) return;
const pair = pairsContainer.querySelector(`.key-value-pair[data-index="${index}"]`);
if (pair) {
pair.remove();
// Re-index remaining pairs
const remainingPairs = pairsContainer.querySelectorAll('.key-value-pair');
remainingPairs.forEach((p, newIndex) => {
p.setAttribute('data-index', newIndex);
const keyInput = p.querySelector('[data-key-index]');
const valueInput = p.querySelector('[data-value-index]');
if (keyInput) {
keyInput.setAttribute('name', keyInput.getAttribute('name').replace(/\[key_\d+\]/, `[key_${newIndex}]`));
keyInput.setAttribute('data-key-index', newIndex);
keyInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${keyInput.getAttribute('name').split('[')[0]}')`);
}
if (valueInput) {
valueInput.setAttribute('name', valueInput.getAttribute('name').replace(/\[value_\d+\]/, `[value_${newIndex}]`));
valueInput.setAttribute('data-value-index', newIndex);
valueInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${valueInput.getAttribute('name').split('[')[0]}')`);
}
const removeButton = p.querySelector('button[onclick*="removeKeyValuePair"]');
if (removeButton) {
removeButton.setAttribute('onclick', `removeKeyValuePair('${fieldId}', ${newIndex})`);
}
});
const hiddenInput = pairsContainer.closest('.key-value-pairs-container').querySelector('input[type="hidden"]');
if (hiddenInput) {
const hiddenName = hiddenInput.getAttribute('name').replace(/_data$/, '');
updateKeyValuePairData(fieldId, hiddenName);
}
// Update add button state
const addButton = pairsContainer.nextElementSibling;
if (addButton) {
const maxProperties = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]);
if (remainingPairs.length < maxProperties) {
addButton.disabled = false;
addButton.style.opacity = '1';
addButton.style.cursor = 'pointer';
}
}
}
};
window.updateKeyValuePairData = function(fieldId, fullKey) {
const pairsContainer = document.getElementById(fieldId + '_pairs');
const hiddenInput = document.getElementById(fieldId + '_data');
if (!pairsContainer || !hiddenInput) return;
const pairs = {};
const keyInputs = pairsContainer.querySelectorAll('[data-key-index]');
const valueInputs = pairsContainer.querySelectorAll('[data-value-index]');
keyInputs.forEach((keyInput, idx) => {
const key = keyInput.value.trim();
const valueInput = Array.from(valueInputs).find(v => v.getAttribute('data-value-index') === keyInput.getAttribute('data-key-index'));
if (key && valueInput) {
const value = valueInput.value.trim();
if (value) {
pairs[key] = value;
}
}
});
hiddenInput.value = JSON.stringify(pairs);
};
window.updateArrayObjectData = function(fieldId) {
const itemsContainer = document.getElementById(fieldId + '_items');
const hiddenInput = document.getElementById(fieldId + '_data');
if (!itemsContainer || !hiddenInput) return;
// Get existing items from hidden input to preserve non-editable properties
let existingItems = [];
try {
const existingData = hiddenInput.value.trim();
if (existingData) {
existingItems = JSON.parse(existingData);
}
} catch (e) {
console.error('Error parsing existing items data:', e);
}
const items = [];
const itemElements = itemsContainer.querySelectorAll('.array-object-item');
itemElements.forEach((itemEl, index) => {
// Start with original item data from data attribute to preserve non-editable properties
// This avoids index-based corruption after deletions/reindexing
let existingItem = {};
const itemDataBase64 = itemEl.getAttribute('data-item-data');
if (itemDataBase64) {
try {
const itemDataJson = decodeURIComponent(escape(atob(itemDataBase64)));
existingItem = JSON.parse(itemDataJson);
} catch (e) {
console.error('Error parsing item data from data attribute:', e);
// Fallback to index-based lookup if data attribute is missing/corrupt
if (index < existingItems.length && existingItems[index]) {
existingItem = existingItems[index];
}
}
} else {
// Fallback to index-based lookup if data attribute is missing
if (index < existingItems.length && existingItems[index]) {
existingItem = existingItems[index];
}
}
const item = Object.assign({}, existingItem); // Copy existing item
// Get all text inputs in this item and overlay their values with type coercion
itemEl.querySelectorAll('input[type="text"], input[type="url"], input[type="number"]').forEach(input => {
const propKey = input.getAttribute('data-prop-key');
if (propKey && propKey !== 'logo_file') {
let value = input.value.trim();
// Type coercion: check input type or data-prop-type attribute
const inputType = input.type;
const propType = input.getAttribute('data-prop-type');
if (inputType === 'number' || propType === 'number') {
// Use valueAsNumber if available, fallback to Number()
const numValue = input.valueAsNumber !== undefined && !isNaN(input.valueAsNumber)
? input.valueAsNumber
: Number(value);
item[propKey] = isNaN(numValue) ? value : numValue;
} else if (propType === 'array' || input.getAttribute('data-prop-is-list') === 'true') {
// Try to parse as JSON array, fallback to comma splitting
try {
const parsed = JSON.parse(value);
item[propKey] = Array.isArray(parsed) ? parsed : value;
} catch (e) {
// Fallback to comma-splitting for arrays
item[propKey] = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
}
} else {
// String value - keep as-is
item[propKey] = value;
}
}
});
// Handle checkboxes
itemEl.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
const propKey = checkbox.getAttribute('data-prop-key');
if (propKey) {
item[propKey] = checkbox.checked;
}
});
// Handle file upload data (stored in data attributes, base64-encoded)
itemEl.querySelectorAll('[data-file-data]').forEach(fileEl => {
const fileDataBase64 = fileEl.getAttribute('data-file-data');
if (fileDataBase64) {
try {
// Decode base64-encoded JSON
const fileDataJson = decodeURIComponent(escape(atob(fileDataBase64)));
const data = JSON.parse(fileDataJson);
const propKey = fileEl.getAttribute('data-prop-key');
if (propKey) {
item[propKey] = data;
}
} catch (e) {
console.error('Error parsing file data:', e);
}
}
});
items.push(item);
// Update data-item-data attribute with the merged item to keep it in sync
try {
const itemDataJson = JSON.stringify(item);
const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson)));
itemEl.setAttribute('data-item-data', itemDataBase64);
} catch (e) {
console.error('Error updating data-item-data attribute:', e);
}
});
hiddenInput.value = JSON.stringify(items);
};
window.handleArrayObjectFileUpload = async function(event, fieldId, itemIndex, propKey, pluginId) {
const file = event.target.files[0];
if (!file) return;
// Derive item element from event instead of constructing ID (works after reindexing)
const itemEl = event.target.closest('.array-object-item');
if (!itemEl) {
console.error('Array object item element not found');
return;
}
// Find file upload container within the item element, scoped to propKey
const fileUploadContainer = itemEl.querySelector(`.file-upload-widget-inline[data-prop-key="${propKey}"]`);
if (!fileUploadContainer) {
console.error('File upload container not found for propKey:', propKey);
return;
}
// Get upload config from data attribute
let uploadConfig = { allowed_types: ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'], max_size_mb: 5 };
const uploadConfigBase64 = fileUploadContainer.getAttribute('data-upload-config');
if (uploadConfigBase64) {
try {
const uploadConfigJson = decodeURIComponent(escape(atob(uploadConfigBase64)));
uploadConfig = JSON.parse(uploadConfigJson);
} catch (e) {
console.error('Error parsing upload config from data attribute:', e);
}
}
// Validate file type using uploadConfig
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'];
if (!allowedTypes.includes(file.type)) {
showNotification(`File ${file.name} is not a valid image type`, 'error');
return;
}
// Validate file size using uploadConfig
const maxSizeMB = uploadConfig.max_size_mb || 5;
if (file.size > maxSizeMB * 1024 * 1024) {
showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
return;
}
// Validate pluginId before upload (fail fast)
if (!pluginId || pluginId === 'null' || pluginId === 'undefined' || (typeof pluginId === 'string' && pluginId.trim() === '')) {
showNotification('Plugin ID is required for file upload', 'error');
console.error('File upload failed: pluginId is required');
return;
}
// Upload file
const formData = new FormData();
formData.append('plugin_id', pluginId);
formData.append('files', file);
try {
const response = await fetch('/api/v3/plugins/assets/upload', {
method: 'POST',
body: formData
});
// Check response.ok before parsing JSON to avoid parsing errors on HTTP errors
if (!response.ok) {
const errorText = await response.text();
let errorMessage = `Upload failed: HTTP ${response.status}`;
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.message || errorMessage;
} catch (e) {
// If response isn't JSON, use the text or status
if (errorText) {
errorMessage = `Upload failed: ${errorText}`;
}
}
showNotification(errorMessage, 'error');
return;
}
const data = await response.json();
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
const uploadedFile = data.uploaded_files[0];
// Store file data in data-file-data attribute on the container (base64-encoded)
const fileDataJson = JSON.stringify(uploadedFile);
const fileDataBase64 = btoa(unescape(encodeURIComponent(fileDataJson)));
fileUploadContainer.setAttribute('data-file-data', fileDataBase64);
fileUploadContainer.setAttribute('data-prop-key', propKey);
// Update the display to show the uploaded image
const existingImage = fileUploadContainer.querySelector('.uploaded-image-container');
if (existingImage) {
existingImage.remove();
}
const imageContainer = document.createElement('div');
imageContainer.className = 'mt-2 flex items-center space-x-2 uploaded-image-container';
const escapedPath = escapeAttribute(uploadedFile.path.replace(/^\/+/, ''));
const escapedFieldId = escapeAttribute(fieldId);
const escapedPropKey = escapeAttribute(propKey);
// Get current item index from data-index attribute for remove button
const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex;
imageContainer.innerHTML = `
<img src="/${escapedPath}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
<button type="button"
onclick="removeArrayObjectFile('${escapedFieldId}', ${currentItemIndex}, '${escapedPropKey}')"
class="text-red-600 hover:text-red-800">
<i class="fas fa-trash"></i> Remove
</button>
`;
fileUploadContainer.appendChild(imageContainer);
// Update the hidden input with the new file data
updateArrayObjectData(fieldId);
showNotification('Logo uploaded successfully', 'success');
} else {
showNotification(`Upload failed: ${data.message || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Upload error:', error);
showNotification(`Upload error: ${error.message}`, 'error');
}
// Clear file input
event.target.value = '';
};
window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) {
const itemId = `${fieldId}_item_${itemIndex}`;
const fileUploadContainer = document.querySelector(`#${itemId} .file-upload-widget-inline`);
if (!fileUploadContainer) {
console.error('File upload container not found');
return;
}
// Remove file data from data attribute
fileUploadContainer.removeAttribute('data-file-data');
// Remove the image display
const imageContainer = fileUploadContainer.querySelector('.uploaded-image-container');
if (imageContainer) {
imageContainer.remove();
}
// Update the hidden input to remove the file data
updateArrayObjectData(fieldId);
showNotification('Logo removed', 'success');
};
// Generic Plugin Action Handler // Generic Plugin Action Handler
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) { window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) {
debugLog('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam); debugLog('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam);
@@ -2770,9 +2263,12 @@ function handleUninstallSuccess(pluginId) {
renderInstalledPlugins(updatedPlugins); renderInstalledPlugins(updatedPlugins);
showNotification(`Plugin uninstalled successfully`, 'success'); showNotification(`Plugin uninstalled successfully`, 'success');
// Also refresh from server to ensure consistency // Also refresh from server to ensure consistency. The uninstall went
// through a plain fetch, so both list caches still hold the plugin:
// refreshInstalledPlugins() bypasses them.
pluginLoadCache.invalidate();
setTimeout(() => { setTimeout(() => {
loadInstalledPlugins(); refreshInstalledPlugins();
}, 1000); }, 1000);
} }
@@ -3978,10 +3474,6 @@ window.dismissGithubWarning = function() {
} }
// Used by the array-of-objects helpers below, which live outside this IIFE.
window.renderArrayObjectItem = renderArrayObjectItem;
window.getSchemaProperty = getSchemaProperty;
// Assigned here because the functions are IIFE-local; the load-time and // Assigned here because the functions are IIFE-local; the load-time and
// htmx:afterSettle wiring below, outside the IIFE, reaches them through window. // htmx:afterSettle wiring below, outside the IIFE, reaches them through window.
window.attachInstallButtonHandler = attachInstallButtonHandler; window.attachInstallButtonHandler = attachInstallButtonHandler;
@@ -3989,134 +3481,6 @@ window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
})(); // End IIFE })(); // End IIFE
// Array-of-objects config fields: add and remove items (the inline onclick
// handlers that renderArrayObjectItem writes call these).
if (typeof window !== 'undefined') {
window.addArrayObjectItem = function(fieldId, fullKey, maxItems) {
const itemsContainer = document.getElementById(fieldId + '_items');
const hiddenInput = document.getElementById(fieldId + '_data');
if (!itemsContainer || !hiddenInput) return;
const currentItems = itemsContainer.querySelectorAll('.array-object-item');
if (currentItems.length >= maxItems) {
alert(`Maximum ${maxItems} items allowed`);
return;
}
// Get schema for item properties - ensure currentPluginConfig is available
// Try window.currentPluginConfig first (most reliable), then currentPluginConfig
const schema = (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema) ||
(typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema);
if (!schema) {
console.error('addArrayObjectItem: Schema not available. currentPluginConfig may not be set.');
return;
}
// Use getSchemaProperty to properly handle nested schemas (e.g., news.custom_feeds)
const arraySchema = window.getSchemaProperty(schema, fullKey);
if (!arraySchema || arraySchema.type !== 'array' || !arraySchema.items) {
return;
}
const itemsSchema = arraySchema.items;
if (!itemsSchema || !itemsSchema.properties) return;
const newIndex = currentItems.length;
// renderArrayObjectItem is exported by the plugin-manager IIFE above.
const itemHtml = window.renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema);
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
window.updateArrayObjectData(fieldId);
// Update add button state
const addButton = itemsContainer.nextElementSibling;
if (addButton && currentItems.length + 1 >= maxItems) {
addButton.disabled = true;
addButton.style.opacity = '0.5';
addButton.style.cursor = 'not-allowed';
}
};
window.removeArrayObjectItem = function(fieldId, index) {
const itemsContainer = document.getElementById(fieldId + '_items');
if (!itemsContainer) return;
const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`);
if (item) {
item.remove();
// Re-index remaining items
// Use data-index for index storage - no need to encode index in onclick strings or IDs
const remainingItems = itemsContainer.querySelectorAll('.array-object-item');
remainingItems.forEach((itemEl, newIndex) => {
itemEl.setAttribute('data-index', newIndex);
// Update all inputs within this item - only update index in array bracket notation
itemEl.querySelectorAll('input, select, textarea').forEach(input => {
const name = input.getAttribute('name');
const id = input.id;
if (name) {
// Only replace index in bracket notation like [0], [1], etc.
// Match pattern: field_name[index] but not field_name123
const newName = name.replace(/\[(\d+)\]/, `[${newIndex}]`);
input.setAttribute('name', newName);
}
if (id) {
// Only update index in specific patterns like _item_0, _item_1
// Match pattern: _item_<digits> but be careful not to break other numeric IDs
const newId = id.replace(/_item_(\d+)/, `_item_${newIndex}`);
input.id = newId;
}
});
// The buttons' inline onclick handlers carry the item index, so
// rewrite it in each one.
itemEl.querySelectorAll('button[onclick]').forEach(button => {
const onclick = button.getAttribute('onclick');
if (onclick) {
// Match patterns like:
// removeArrayObjectItem('fieldId', 0)
// handleArrayObjectFileUpload(event, 'fieldId', 0, 'propKey', 'pluginId')
// removeArrayObjectFile('fieldId', 0, 'propKey')
// Only replace the numeric index parameter (second or third argument depending on function)
let newOnclick = onclick;
// For removeArrayObjectItem('fieldId', index) - second param
newOnclick = newOnclick.replace(
/removeArrayObjectItem\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*\)/g,
`removeArrayObjectItem('$1', ${newIndex})`
);
// For handleArrayObjectFileUpload(event, 'fieldId', index, ...) - third param
newOnclick = newOnclick.replace(
/handleArrayObjectFileUpload\s*\(\s*event\s*,\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
`handleArrayObjectFileUpload(event, '$1', ${newIndex},`
);
// For removeArrayObjectFile('fieldId', index, ...) - second param
newOnclick = newOnclick.replace(
/removeArrayObjectFile\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
`removeArrayObjectFile('$1', ${newIndex},`
);
button.setAttribute('onclick', newOnclick);
}
});
});
window.updateArrayObjectData(fieldId);
// Update add button state
const addButton = itemsContainer.nextElementSibling;
if (addButton && addButton.getAttribute('onclick')) {
// Extract maxItems from onclick attribute more safely
// Pattern: addArrayObjectItem('fieldId', 'fullKey', maxItems)
const onclickMatch = addButton.getAttribute('onclick').match(/addArrayObjectItem\s*\([^,]+,\s*[^,]+,\s*(\d+)\)/);
if (onclickMatch && onclickMatch[1]) {
const maxItems = parseInt(onclickMatch[1]);
if (remainingItems.length < maxItems) {
addButton.disabled = false;
addButton.style.opacity = '1';
addButton.style.cursor = 'pointer';
}
}
}
}
};
}
// Force initialization immediately when script loads (for HTMX swapped content) // Force initialization immediately when script loads (for HTMX swapped content)
debugLog('Plugins script loaded, checking for elements...'); debugLog('Plugins script loaded, checking for elements...');
+1 -1
View File
@@ -911,7 +911,7 @@
LEDDialog, the widgets and app-shell.js's updatePlugin at load time LEDDialog, the widgets and app-shell.js's updatePlugin at load time
or when a card is clicked, so it must follow all of them. No other or when a card is clicked, so it must follow all of them. No other
script defines the same globals. --> script defines the same globals. -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script> <script src="{{ url_for('static', filename='v3/plugins_manager.js') }}" defer></script>
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the <!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
deferred widget's window assignments always shadowed the inline deferred widget's window assignments always shadowed the inline
@@ -282,7 +282,7 @@
if (data.status === 'success' && data.data) { if (data.status === 'success' && data.data) {
const select = document.getElementById('history-plugin-filter'); const select = document.getElementById('history-plugin-filter');
if (select) { if (select) {
const plugins = Object.keys(data.data); const plugins = (data.data.plugins || []).map(p => p.id).filter(Boolean);
plugins.forEach(pluginId => { plugins.forEach(pluginId => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = pluginId; option.value = pluginId;
@@ -424,7 +424,7 @@
<dd class="text-lg font-semibold text-gray-900" id="ledmatrix-version">Loading...</dd> <dd class="text-lg font-semibold text-gray-900" id="ledmatrix-version">Loading...</dd>
</div> </div>
</div> </div>
<button onclick="checkForUpdates()" <button onclick="checkForUpdates(this)"
class="inline-flex items-center px-3 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50"> class="inline-flex items-center px-3 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50">
<i class="fas fa-sync-alt mr-2"></i> <i class="fas fa-sync-alt mr-2"></i>
Check Updates Check Updates
@@ -458,7 +458,7 @@
hx-vals='{"action": "git_pull"}' hx-vals='{"action": "git_pull"}'
hx-confirm="This will stash any local changes and update the code. Continue?" hx-confirm="This will stash any local changes and update the code. Continue?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);" hx-on:htmx:after-request="var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; if (d.restart_required) window.notifyCodeUpdateRestart(); } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i> <i class="fas fa-download mr-2"></i>
Update Code Update Code
@@ -575,14 +575,27 @@
}); });
})(); })();
// Check for updates function // A pull only replaces files on disk; the running services keep the code
window.checkForUpdates = function() { // they loaded at boot. Same wording as the update banner in base.html.
const btn = event.target.closest('button'); window.notifyCodeUpdateRestart = function() {
if (typeof window.showRestartPending === 'function') {
window.showRestartPending(
'Update installed \u2014 restart the display and web ' +
'services to run the new code');
}
};
// Check for updates function: the same git_pull (and the same confirmation)
// as the Update Code quick action below.
window.checkForUpdates = function(btn) {
if (!confirm('This will stash any local changes and update the code. Continue?')) {
return;
}
if (btn) { if (btn) {
const originalContent = btn.innerHTML; const originalContent = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Checking...'; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Checking...';
fetch('/api/v3/system/action', { fetch('/api/v3/system/action', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -592,9 +605,10 @@ window.checkForUpdates = function() {
.then(data => { .then(data => {
btn.innerHTML = originalContent; btn.innerHTML = originalContent;
btn.disabled = false; btn.disabled = false;
if (data.status === 'success') { if (data.status === 'success') {
showNotification('Update successful: ' + (data.stdout || 'Code updated'), 'success'); showNotification(data.message || 'Code updated', 'success');
if (data.restart_required) window.notifyCodeUpdateRestart();
// Reload version after a short delay // Reload version after a short delay
setTimeout(() => { setTimeout(() => {
fetch('/api/v3/system/version') fetch('/api/v3/system/version')
@@ -607,7 +621,7 @@ window.checkForUpdates = function() {
}); });
}, 1000); }, 1000);
} else { } else {
showNotification('Update failed: ' + (data.stderr || data.message || 'Unknown error'), 'error'); showNotification(data.message || 'Update failed', 'error');
} }
}) })
.catch(error => { .catch(error => {
@@ -156,7 +156,7 @@ function manualValidateJson(editorId, validationDivId) {
<div> <div>
<div class="font-semibold text-red-800">✗ Invalid JSON syntax</div> <div class="font-semibold text-red-800">✗ Invalid JSON syntax</div>
<div class="text-sm text-red-700 mt-1"> <div class="text-sm text-red-700 mt-1">
<strong>Error:</strong> ${e.message} <strong>Error:</strong> ${window.LEDEscape.html(e.message)}
</div> </div>
</div> </div>
</div> </div>
+14 -4
View File
@@ -451,9 +451,11 @@
}) })
.then(r => { .then(r => {
if (!r.ok) { if (!r.ok) {
// Only a non-JSON body falls back to the status code; the
// rejection carrying the server's message must not be caught.
return r.json() return r.json()
.then(d => Promise.reject(new Error(d.message || `HTTP ${r.status}`))) .catch(() => null)
.catch(() => Promise.reject(new Error(`HTTP ${r.status}`))); .then(d => Promise.reject(new Error((d && d.message) || `HTTP ${r.status}`)));
} }
return r.json(); return r.json();
}) })
@@ -465,6 +467,14 @@
showOutput ? (data.output || '') : '', showOutput ? (data.output || '') : '',
showPluginDetails ? (data.details || []) : null showPluginDetails ? (data.details || []) : null
); );
// A pull replaces files on disk; the services keep running the
// code they loaded at boot until restarted.
if (ok && action === 'git_pull' && data.restart_required &&
typeof window.showRestartPending === 'function') {
window.showRestartPending(
'Update installed \u2014 restart the display and web ' +
'services to run the new code');
}
}) })
.catch(err => { .catch(err => {
showResult(resultId, false, 'Request failed: ' + err.message); showResult(resultId, false, 'Request failed: ' + err.message);
@@ -717,8 +727,8 @@
fetch('/api/v3/system/status') fetch('/api/v3/system/status')
.then(r => { .then(r => {
if (!r.ok) return r.json() if (!r.ok) return r.json()
.then(d => Promise.reject(d.message || `HTTP ${r.status}`)) .catch(() => null)
.catch(() => Promise.reject(`HTTP ${r.status}`)); .then(d => Promise.reject((d && d.message) || `HTTP ${r.status}`));
return r.json(); return r.json();
}) })
.then(res => { .then(res => {