diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index e2dff6f9..c205d37c 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -25,7 +25,7 @@ jobs: - name: Run Claude Code Review id: claude-review - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # Review PRs opened by the Claude GitHub App. Without this the action diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index cd9e45a9..16b2a6a2 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -32,7 +32,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 266bf0f1..5057fd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +- Web UI cleanup and dependency pins: + - A plugin's own config widget (`/static/plugin-widgets//.js`) is requested with `?v=`, so an updated plugin's widget reaches browsers instead of the copy cached as immutable for a year. + - A failed installed-plugins reload after a toggle, install or uninstall shows one error, not a second generic "unexpected error" toast. + - The timezone picker on the General tab renders again when the tab is reloaded in the same page session. + - Removed dead code: the plugin-action button's six plugin-id fallbacks (the button always passes its id) and its `[DEBUG]` logging, `window.currentPluginConfig` (never set to anything but `null`), the file-upload widget's JSON delete branch (its endpoint never existed), unused `PluginAPI` / `PluginInstallManager` / `PluginStateManager` helpers, `loadPluginWidgetsFromManifest`, no-longer-reachable fallbacks for a stale `install_manager.js` and a missing `LEDVisibility`, and 13 unused CSS utility rules. + - `pytz` may be any release before 2027, so current timezone data installs; `requirements-test.txt` caps `psutil` below 7 like the runtime requirements and allows `pytest-cov` up to 7.x (checked against pytest 9 with the CI coverage run). + - The Claude GitHub Actions workflows pin `anthropics/claude-code-action` to a commit SHA like the other actions. - Core services and `src.common` fixes: - A plugin font declared as a `.zip` URL is served as the font extracted from it after a restart, instead of registering the archive itself. Font downloads time out after 30s and land in the cache only once complete, so an interrupted download is retried rather than served forever. - `APIHelper`'s rate limit and the display-sync heartbeat/leader timeouts measure elapsed time with `time.monotonic()`. A wall-clock step (NTP correcting a Pi with no RTC) could stall API requests for as long as the step or fake a sync timeout. `get_request_stats()['last_request_time']` is still wall-clock time. diff --git a/requirements-test.txt b/requirements-test.txt index e4e19398..e1f7dfc6 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,10 +1,10 @@ # Test/dev-only dependencies (not needed on a running display). # Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt pytest>=9.0.3,<10.0.0 -pytest-cov>=4.1.0,<5.0.0 +pytest-cov>=4.1.0,<8.0.0 pytest-mock>=3.11.0,<4.0.0 freezegun>=1.2,<2 # deterministic time for golden-image tests -psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the +psutil>=6.0.0,<7.0.0 # optional at runtime; installed for tests so the # /system/status endpoint's real path is exercised mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml) PyYAML>=6.0.2,<7.0.0 # not a core dependency: test_starlark_pixlet_routes loads diff --git a/requirements.txt b/requirements.txt index 5efdc747..f91b55d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ Pillow>=12.2.0,<13.0.0 numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x) # Timezone handling -pytz>=2024.2,<2025.0 # Updated for latest timezone data +pytz>=2024.2,<2027.0 # Updated for latest timezone data # HTTP requests requests>=2.33.0,<3.0.0 diff --git a/test/js/unit/test_file_upload_widget.js b/test/js/unit/test_file_upload_widget.js index 1da932ea..8d261aa4 100644 --- a/test/js/unit/test_file_upload_widget.js +++ b/test/js/unit/test_file_upload_widget.js @@ -83,7 +83,6 @@ global.document = { global.window = global; require('../led_escape').install(window); window.LEDMatrixWidgets = { register() {} }; -window.currentPluginConfig = null; window.getUploadConfig = () => ({ plugin_id: 'static-image' }); // eslint-disable-next-line no-eval diff --git a/test/js/unit/test_html_escaping.js b/test/js/unit/test_html_escaping.js index 852dcf45..c3289213 100644 --- a/test/js/unit/test_html_escaping.js +++ b/test/js/unit/test_html_escaping.js @@ -89,8 +89,6 @@ const ESCAPERS = [ 'static/v3/plugins_manager.js', 'function escapeHtml(text) {', 'escapeHtml', false], ['plugins_manager.js (starlark escapeHtml)', 'static/v3/plugins_manager.js', 'function escapeHtml(str) {', 'escapeHtml', false], - ['error_handler.js (escapeHtml)', - 'static/v3/js/utils/error_handler.js', 'function escapeHtml(text) {', 'escapeHtml', false], ['json-file-manager.js (_esc)', 'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true], ['plugin-file-manager.js (escHtml)', diff --git a/test/web_interface/test_plugin_widget_route.py b/test/web_interface/test_plugin_widget_route.py index b4858108..c8ac276e 100644 --- a/test/web_interface/test_plugin_widget_route.py +++ b/test/web_interface/test_plugin_widget_route.py @@ -215,7 +215,7 @@ def config_form(tmp_path): orig_cm = getattr(pv.pages_v3, "config_manager", None) def _render(plugin_id="soccer-scoreboard", schema=None, widgets=None, - files=None): + files=None, version=None): d = _make_plugin(tmp_path, plugin_id, widgets=widgets, files=files) (d / "config_schema.json").write_text( json.dumps(schema or {"type": "object", "properties": {}}), @@ -224,6 +224,8 @@ def config_form(tmp_path): pm = MagicMock() pm.plugins_dir = str(tmp_path) pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id} + if version is not None: + pm.get_plugin_info.return_value["version"] = version pm.get_plugin.return_value = None pv.pages_v3.plugin_manager = pm @@ -261,6 +263,23 @@ class TestTheFormRequestsPluginWidgets: assert "ensureWidget" in body assert '"custom-leagues"' in body + def test_the_plugin_version_is_passed_to_the_loader(self, config_form): + """/static/ is cached as immutable for a year, so the loader adds + ?v=; without it an updated widget never reaches the browser.""" + r = config_form(schema=_schema_with_widget("custom-leagues"), + widgets=[{"name": "custom-leagues"}], + files={"custom-leagues.js": WIDGET_BODY}, + version="1.4.2") + body = r.get_data(as_text=True) + assert 'var VERSION = "1.4.2";' in body + assert "ensureWidget(WIDGET, PLUGIN, VERSION)" in body + + def test_a_plugin_without_a_version_passes_an_empty_one(self, config_form): + r = config_form(schema=_schema_with_widget("custom-leagues"), + widgets=[{"name": "custom-leagues"}], + files={"custom-leagues.js": WIDGET_BODY}) + assert 'var VERSION = "";' in r.get_data(as_text=True) + def test_the_text_input_remains_as_the_fallback(self, config_form): """A widget that fails to load must not cost the user their value.""" r = config_form(schema=_schema_with_widget("custom-leagues"), diff --git a/web_interface/requirements.txt b/web_interface/requirements.txt index 545fe283..01f24373 100644 --- a/web_interface/requirements.txt +++ b/web_interface/requirements.txt @@ -32,7 +32,7 @@ numpy>=1.24.0 requests>=2.33.0,<3.0.0 # Timezone handling (must match main requirements) -pytz>=2024.2,<2025.0 +pytz>=2024.2,<2027.0 # Spotify integration (must match main requirements) spotipy>=2.25.2,<3.0.0 diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index cb260361..e24fdd26 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -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); } diff --git a/web_interface/static/v3/js/app-shell.js b/web_interface/static/v3/js/app-shell.js index 60937887..6bf6c3cd 100644 --- a/web_interface/static/v3/js/app-shell.js +++ b/web_interface/static/v3/js/app-shell.js @@ -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); diff --git a/web_interface/static/v3/js/htmx-config.js b/web_interface/static/v3/js/htmx-config.js index 883c3d46..6fb913f2 100644 --- a/web_interface/static/v3/js/htmx-config.js +++ b/web_interface/static/v3/js/htmx-config.js @@ -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; } }); diff --git a/web_interface/static/v3/js/plugins/api_client.js b/web_interface/static/v3/js/plugins/api_client.js index 23fee731..1f9cad65 100644 --- a/web_interface/static/v3/js/plugins/api_client.js +++ b/web_interface/static/v3/js/plugins/api_client.js @@ -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 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} 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} 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} Response data - */ - async uninstallPlugin(pluginId) { - return await this.request('/plugins/uninstall', 'POST', { - plugin_id: pluginId - }); - }, - - /** - * Get plugin store. - * - * @returns {Promise} List of available plugins - */ - async getPluginStore() { - const response = await this.request('/plugins/store/list'); - return response.data || []; - }, - /** * Get plugin health. * diff --git a/web_interface/static/v3/js/plugins/install_manager.js b/web_interface/static/v3/js/plugins/install_manager.js index f7d284f9..d399eb39 100644 --- a/web_interface/static/v3/js/plugins/install_manager.js +++ b/web_interface/static/v3/js/plugins/install_manager.js @@ -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} 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} 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} 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. * diff --git a/web_interface/static/v3/js/plugins/state_manager.js b/web_interface/static/v3/js/plugins/state_manager.js index 99dcf4b8..30b9282b 100644 --- a/web_interface/static/v3/js/plugins/state_manager.js +++ b/web_interface/static/v3/js/plugins/state_manager.js @@ -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} 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} 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; - } } }; diff --git a/web_interface/static/v3/js/utils/error_handler.js b/web_interface/static/v3/js/utils/error_handler.js index 5cb9a6a4..f0fd0700 100644 --- a/web_interface/static/v3/js/utils/error_handler.js +++ b/web_interface/static/v3/js/utils/error_handler.js @@ -176,19 +176,19 @@ function showErrorModal(error, context, message, suggestions, docLink) { } // Build modal content - const contextText = context ? `
${escapeHtml(context)}
` : ''; + const contextText = context ? `
${window.LEDEscape.html(context)}
` : ''; const suggestionsHtml = suggestions.length > 0 ? `

Suggested fixes:

    - ${suggestions.map(s => `
  • ${escapeHtml(s)}
  • `).join('')} + ${suggestions.map(s => `
  • ${window.LEDEscape.html(s)}
  • `).join('')}
` : ''; const docLinkHtml = docLink ? ` @@ -198,14 +198,14 @@ function showErrorModal(error, context, message, suggestions, docLink) {
Technical details -
${escapeHtml(error.details)}
+
${window.LEDEscape.html(error.details)}
` : ''; const errorCodeHtml = error.error_code ? `
- Error code: ${escapeHtml(error.error_code)} + Error code: ${window.LEDEscape.html(error.error_code)}
` : ''; @@ -223,7 +223,7 @@ function showErrorModal(error, context, message, suggestions, docLink) {

Something went wrong

${contextText} -

${escapeHtml(message)}

+

${window.LEDEscape.html(message)}

${errorCodeHtml} ${suggestionsHtml} ${docLinkHtml} @@ -284,8 +284,6 @@ function closeErrorModal() { } } -function escapeHtml(text) { return window.LEDEscape.html(text); } - /** * Copy error details to clipboard. * diff --git a/web_interface/static/v3/js/widgets/file-upload.js b/web_interface/static/v3/js/widgets/file-upload.js index 791f1406..0900b374 100644 --- a/web_interface/static/v3/js/widgets/file-upload.js +++ b/web_interface/static/v3/js/widgets/file-upload.js @@ -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; diff --git a/web_interface/static/v3/js/widgets/plugin-loader.js b/web_interface/static/v3/js/widgets/plugin-loader.js index 5b7ff822..f801a8d4 100644 --- a/web_interface/static/v3/js/widgets/plugin-loader.js +++ b/web_interface/static/v3/js/widgets/plugin-loader.js @@ -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} 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// or // /static/plugins//, 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} 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 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; - }; })(); diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index d7acc445..53000918 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -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 = `${window.LEDEscape.html(data.message || 'Installation failed')}`; @@ -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) { diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 68cf3658..910d9a12 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -275,17 +275,17 @@ + + - -