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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 10:40:52 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 65d82580bc
commit 0e9e2cabba
24 changed files with 109 additions and 579 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: Run Claude Code Review - name: Run Claude Code Review
id: claude-review id: claude-review
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1
with: with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Review PRs opened by the Claude GitHub App. Without this the action # Review PRs opened by the Claude GitHub App. Without this the action
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: Run Claude Code - name: Run Claude Code
id: claude id: claude
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1
with: with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+7
View File
@@ -19,6 +19,13 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## Unreleased
- Web UI cleanup and dependency pins:
- A plugin's own config widget (`/static/plugin-widgets/<id>/<widget>.js`) is requested with `?v=<plugin version>`, 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: - 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. - 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. - `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.
+2 -2
View File
@@ -1,10 +1,10 @@
# Test/dev-only dependencies (not needed on a running display). # Test/dev-only dependencies (not needed on a running display).
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt # Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
pytest>=9.0.3,<10.0.0 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 pytest-mock>=3.11.0,<4.0.0
freezegun>=1.2,<2 # deterministic time for golden-image tests 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 # /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) 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 PyYAML>=6.0.2,<7.0.0 # not a core dependency: test_starlark_pixlet_routes loads
+1 -1
View File
@@ -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) numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
# Timezone handling # Timezone handling
pytz>=2024.2,<2025.0 # Updated for latest timezone data pytz>=2024.2,<2027.0 # Updated for latest timezone data
# HTTP requests # HTTP requests
requests>=2.33.0,<3.0.0 requests>=2.33.0,<3.0.0
-1
View File
@@ -83,7 +83,6 @@ global.document = {
global.window = global; global.window = global;
require('../led_escape').install(window); require('../led_escape').install(window);
window.LEDMatrixWidgets = { register() {} }; window.LEDMatrixWidgets = { register() {} };
window.currentPluginConfig = null;
window.getUploadConfig = () => ({ plugin_id: 'static-image' }); window.getUploadConfig = () => ({ plugin_id: 'static-image' });
// eslint-disable-next-line no-eval // eslint-disable-next-line no-eval
-2
View File
@@ -89,8 +89,6 @@ const ESCAPERS = [
'static/v3/plugins_manager.js', 'function escapeHtml(text) {', 'escapeHtml', false], 'static/v3/plugins_manager.js', 'function escapeHtml(text) {', 'escapeHtml', false],
['plugins_manager.js (starlark escapeHtml)', ['plugins_manager.js (starlark escapeHtml)',
'static/v3/plugins_manager.js', 'function escapeHtml(str) {', 'escapeHtml', false], '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)', ['json-file-manager.js (_esc)',
'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true], 'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true],
['plugin-file-manager.js (escHtml)', ['plugin-file-manager.js (escHtml)',
+20 -1
View File
@@ -215,7 +215,7 @@ def config_form(tmp_path):
orig_cm = getattr(pv.pages_v3, "config_manager", None) orig_cm = getattr(pv.pages_v3, "config_manager", None)
def _render(plugin_id="soccer-scoreboard", schema=None, widgets=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 = _make_plugin(tmp_path, plugin_id, widgets=widgets, files=files)
(d / "config_schema.json").write_text( (d / "config_schema.json").write_text(
json.dumps(schema or {"type": "object", "properties": {}}), json.dumps(schema or {"type": "object", "properties": {}}),
@@ -224,6 +224,8 @@ def config_form(tmp_path):
pm = MagicMock() pm = MagicMock()
pm.plugins_dir = str(tmp_path) pm.plugins_dir = str(tmp_path)
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id} 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 pm.get_plugin.return_value = None
pv.pages_v3.plugin_manager = pm pv.pages_v3.plugin_manager = pm
@@ -261,6 +263,23 @@ class TestTheFormRequestsPluginWidgets:
assert "ensureWidget" in body assert "ensureWidget" in body
assert '"custom-leagues"' 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=<version>; 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): 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.""" """A widget that fails to load must not cost the user their value."""
r = config_form(schema=_schema_with_widget("custom-leagues"), r = config_form(schema=_schema_with_widget("custom-leagues"),
+1 -1
View File
@@ -32,7 +32,7 @@ numpy>=1.24.0
requests>=2.33.0,<3.0.0 requests>=2.33.0,<3.0.0
# Timezone handling (must match main requirements) # Timezone handling (must match main requirements)
pytz>=2024.2,<2025.0 pytz>=2024.2,<2027.0
# Spotify integration (must match main requirements) # Spotify integration (must match main requirements)
spotipy>=2.25.2,<3.0.0 spotipy>=2.25.2,<3.0.0
+3 -25
View File
@@ -299,8 +299,6 @@ html[data-theme="dark"] {
.gap-x-2 { column-gap: 0.5rem; } .gap-x-2 { column-gap: 0.5rem; }
.gap-x-3 { column-gap: 0.75rem; } .gap-x-3 { column-gap: 0.75rem; }
.gap-x-4 { column-gap: 1rem; } .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; } .gap-y-2 { row-gap: 0.5rem; }
/* Enhanced Typography */ /* Enhanced Typography */
@@ -329,7 +327,6 @@ h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; } h4 { font-size: 1.125rem; }
.border-b { border-bottom-width: 1px; } .border-b { border-bottom-width: 1px; }
.border-b-2 { border-bottom-width: 2px; }
.relative { position: relative; } .relative { position: relative; }
.fixed { position: fixed; } .fixed { position: fixed; }
@@ -339,14 +336,11 @@ h4 { font-size: 1.125rem; }
.top-4 { top: 1rem; } .top-4 { top: 1rem; }
.right-4 { right: 1rem; } .right-4 { right: 1rem; }
.max-w-7xl { max-width: 56rem; }
.max-w-full { max-width: 100%; } .max-w-full { max-width: 100%; }
.mx-auto { margin-left: auto; margin-right: auto; } .mx-auto { margin-left: auto; margin-right: auto; }
.overflow-x-auto { overflow-x: auto; } .overflow-x-auto { overflow-x: auto; }
.overflow-hidden { overflow: hidden; } .overflow-hidden { overflow: hidden; }
.aspect-video { aspect-ratio: 16 / 9; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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; } .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-400 { border-color: #9ca3af; }
.border-gray-600 { border-color: #4b5563; } .border-gray-600 { border-color: #4b5563; }
.border-green-200 { border-color: #bbf7d0; } .border-green-200 { border-color: #bbf7d0; }
.border-green-300 { border-color: #86efac; }
.border-green-400 { border-color: #4ade80; } .border-green-400 { border-color: #4ade80; }
.border-green-500 { border-color: #22c55e; } .border-green-500 { border-color: #22c55e; }
.border-l-0 { border-left-width: 0px; border-left-style: solid; } .border-l-0 { border-left-width: 0px; border-left-style: solid; }
.border-purple-200 { border-color: #e9d5ff; } .border-purple-200 { border-color: #e9d5ff; }
.border-purple-300 { border-color: #d8b4fe; }
.border-r-0 { border-right-width: 0px; border-right-style: solid; } .border-r-0 { border-right-width: 0px; border-right-style: solid; }
.border-red-200 { border-color: #fecaca; } .border-red-200 { border-color: #fecaca; }
.border-red-300 { border-color: #fca5a5; } .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-t { border-top-width: 1px; border-top-style: solid; }
.border-yellow-200 { border-color: #fef08a; } .border-yellow-200 { border-color: #fef08a; }
.border-yellow-300 { border-color: #fde047; } .border-yellow-300 { border-color: #fde047; }
.border-yellow-400 { border-color: #facc15; }
.break-all { word-break: break-all; } .break-all { word-break: break-all; }
.break-words { overflow-wrap: break-word; } .break-words { overflow-wrap: break-word; }
.capitalize { text-transform: capitalize; } .capitalize { text-transform: capitalize; }
@@ -570,7 +561,6 @@ a, button, input, select, textarea {
.h-12 { height: 3rem; } .h-12 { height: 3rem; }
.h-20 { height: 5rem; } .h-20 { height: 5rem; }
.h-3 { height: 0.75rem; } .h-3 { height: 0.75rem; }
.h-40 { height: 10rem; }
.h-5 { height: 1.25rem; } .h-5 { height: 1.25rem; }
.h-6 { height: 1.5rem; } .h-6 { height: 1.5rem; }
.h-64 { height: 16rem; } .h-64 { height: 16rem; }
@@ -642,7 +632,6 @@ a, button, input, select, textarea {
.pointer-events-auto { pointer-events: auto; } .pointer-events-auto { pointer-events: auto; }
.pointer-events-none { pointer-events: none; } .pointer-events-none { pointer-events: none; }
.pr-10 { padding-right: 2.5rem; } .pr-10 { padding-right: 2.5rem; }
.pr-2 { padding-right: 0.5rem; }
.pr-20 { padding-right: 5rem; } .pr-20 { padding-right: 5rem; }
.pr-3 { padding-right: 0.75rem; } .pr-3 { padding-right: 0.75rem; }
.pr-4 { padding-right: 1rem; } .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); } .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-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-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-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; } .rounded-t-lg { border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; }
.self-center { align-self: center; } .self-center { align-self: center; }
@@ -819,7 +807,6 @@ a, button, input, select, textarea {
.hover\:text-yellow-900:hover { color: #713f12; } .hover\:text-yellow-900:hover { color: #713f12; }
.hover\:underline:hover { text-decoration-line: underline; text-underline-offset: 0.15em; } .hover\:underline:hover { text-decoration-line: underline; text-underline-offset: 0.15em; }
.last\:border-0:last-child { border-width: 0px; } .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\: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-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; } .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 Toggle States */
.filter-pill, .filter-pill {
.category-filter-pill {
cursor: pointer; cursor: pointer;
user-select: none; 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; 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"], .filter-pill[data-active="true"] {
.category-filter-pill[data-active="true"] {
background-color: var(--color-info-bg); background-color: var(--color-info-bg);
border-color: var(--color-info); border-color: var(--color-info);
color: var(--color-info); color: var(--color-info);
font-weight: 600; font-weight: 600;
} }
.filter-pill[data-active="true"]:hover, .filter-pill[data-active="true"]:hover {
.category-filter-pill[data-active="true"]:hover {
opacity: 0.85; opacity: 0.85;
} }
.category-filter-pill[data-active="true"] {
box-shadow: 0 0 0 1px var(--color-info);
}
/* Section Headers with Subtle Gradients */ /* Section Headers with Subtle Gradients */
.section-header { .section-header {
background: linear-gradient(135deg, rgb(255 255 255 / 90%) 0%, rgb(249 250 251 / 90%) 100%); 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-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-100 { border-color: var(--color-border); }
[data-theme="dark"] .border-gray-400 { border-color: #6b7280; } [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-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-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"] .border-yellow-300 { border-color: rgb(234 179 8 / 55%); }
[data-theme="dark"] .divide-gray-100 > :not(:first-child) { border-color: var(--color-border); } [data-theme="dark"] .divide-gray-100 > :not(:first-child) { border-color: var(--color-border); }
-5
View File
@@ -652,11 +652,6 @@ function markPanelLoadFailed(event) {
window.installedPlugins = this.installedPlugins; window.installedPlugins = this.installedPlugins;
debugLog(`[FULL] Loaded ${this.installedPlugins.length} plugins:`, this.installedPlugins.map(p => p.id)); 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(); this.updatePluginTabs();
} else { } else {
console.error('[FULL] Failed to load plugins:', data.message); console.error('[FULL] Failed to load plugins:', data.message);
+4 -9
View File
@@ -12,44 +12,39 @@
if (!target) { if (!target) {
console.warn('[HTMX] Target is null, skipping swap'); console.warn('[HTMX] Target is null, skipping swap');
event.detail.shouldSwap = false; event.detail.shouldSwap = false;
return false; return;
} }
// Check if target is a valid DOM element // Check if target is a valid DOM element
if (!(target instanceof Element)) { if (!(target instanceof Element)) {
console.warn('[HTMX] Target is not a valid Element, skipping swap'); console.warn('[HTMX] Target is not a valid Element, skipping swap');
event.detail.shouldSwap = false; event.detail.shouldSwap = false;
return false; return;
} }
// Check if target has a parent node (required for insertBefore) // Check if target has a parent node (required for insertBefore)
if (!target.parentNode) { if (!target.parentNode) {
console.warn('[HTMX] Target has no parent node, skipping swap'); console.warn('[HTMX] Target has no parent node, skipping swap');
event.detail.shouldSwap = false; event.detail.shouldSwap = false;
return false; return;
} }
// Ensure target is in the DOM // Ensure target is in the DOM
if (!document.body.contains(target) && !document.head.contains(target)) { if (!document.body.contains(target) && !document.head.contains(target)) {
console.warn('[HTMX] Target is not in DOM, skipping swap'); console.warn('[HTMX] Target is not in DOM, skipping swap');
event.detail.shouldSwap = false; event.detail.shouldSwap = false;
return false; return;
} }
// Additional check: ensure parent is also in DOM // Additional check: ensure parent is also in DOM
if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) { if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) {
console.warn('[HTMX] Target parent is not in DOM, skipping swap'); console.warn('[HTMX] Target parent is not in DOM, skipping swap');
event.detail.shouldSwap = false; event.detail.shouldSwap = false;
return false;
} }
// All checks passed, allow swap
return true;
} catch (e) { } catch (e) {
// If validation fails, cancel swap // If validation fails, cancel swap
console.warn('[HTMX] Error validating target:', e); console.warn('[HTMX] Error validating target:', e);
event.detail.shouldSwap = false; event.detail.shouldSwap = false;
return false;
} }
}); });
@@ -31,8 +31,6 @@ const RequestThrottler = {
debugLog('[RequestThrottler] Creating new request for:', key); debugLog('[RequestThrottler] Creating new request for:', key);
// Create throttled request with abort support
let abortController = null;
const promise = new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
setTimeout(async () => { setTimeout(async () => {
try { try {
@@ -54,22 +52,8 @@ const RequestThrottler = {
this.pending.delete(key); this.pending.delete(key);
} }
}, delay); }, 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); this.pending.set(key, promise);
return promise; return promise;
}, },
@@ -83,18 +67,6 @@ const RequestThrottler = {
} else { } else {
this.cache.clear(); this.cache.clear();
} }
},
/**
* Get statistics about pending requests and cache
*/
getStats() {
return {
pendingCount: this.pending.size,
cacheSize: this.cache.size,
pendingKeys: Array.from(this.pending.keys()),
cacheKeys: Array.from(this.cache.keys())
};
} }
}; };
@@ -225,18 +197,6 @@ const PluginAPI = {
} }
}, },
/**
* Batch multiple requests together for better performance
*
* @param {Array} requests - Array of {endpoint, method, data} objects
* @returns {Promise<Array>} Array of response data
*/
async batch(requests) {
return Promise.all(requests.map(req =>
this.request(req.endpoint, req.method || 'GET', req.data || null, false)
));
},
/** /**
* Clear API cache * Clear API cache
*/ */
@@ -308,34 +268,6 @@ const PluginAPI = {
return await this.request(`/plugins/config/reset?plugin_id=${encodeURIComponent(pluginId)}`, 'POST'); return await this.request(`/plugins/config/reset?plugin_id=${encodeURIComponent(pluginId)}`, 'POST');
}, },
/**
* Get plugin schema.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Plugin schema
*/
async getPluginSchema(pluginId) {
const response = await this.request(`/plugins/schema?plugin_id=${encodeURIComponent(pluginId)}`);
return response.data?.schema || null;
},
/**
* Install plugin from store.
*
* @param {string} pluginId - Plugin identifier
* @param {string} branch - Optional branch name to install from
* @returns {Promise<Object>} Response data
*/
async installPlugin(pluginId, branch = null) {
const data = {
plugin_id: pluginId
};
if (branch) {
data.branch = branch;
}
return await this.request('/plugins/install', 'POST', data);
},
/** /**
* Update plugin. * Update plugin.
* *
@@ -348,28 +280,6 @@ const PluginAPI = {
}); });
}, },
/**
* Uninstall plugin.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Response data
*/
async uninstallPlugin(pluginId) {
return await this.request('/plugins/uninstall', 'POST', {
plugin_id: pluginId
});
},
/**
* Get plugin store.
*
* @returns {Promise<Array>} List of available plugins
*/
async getPluginStore() {
const response = await this.request('/plugins/store/list');
return response.data || [];
},
/** /**
* Get plugin health. * Get plugin health.
* *
@@ -1,83 +1,11 @@
/** /**
* Plugin installation and update management. * 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 = { const PluginInstallManager = {
/**
* Install a plugin.
*
* @param {string} pluginId - Plugin identifier
* @param {string} branch - Optional branch name to install from
* @returns {Promise<Object>} Installation result
*/
async install(pluginId, branch = null) {
try {
const result = await window.PluginAPI.installPlugin(pluginId, branch);
// Refresh installed plugins list
if (window.PluginStateManager) {
await window.PluginStateManager.loadInstalledPlugins();
}
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to install plugin ${pluginId}`);
}
throw error;
}
},
/**
* Update a plugin.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Update result
*/
async update(pluginId) {
try {
const result = await window.PluginAPI.updatePlugin(pluginId);
// Refresh installed plugins list
if (window.PluginStateManager) {
await window.PluginStateManager.loadInstalledPlugins();
}
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to update plugin ${pluginId}`);
}
throw error;
}
},
/**
* Uninstall a plugin.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Uninstall result
*/
async uninstall(pluginId) {
try {
const result = await window.PluginAPI.uninstallPlugin(pluginId);
// Refresh installed plugins list
if (window.PluginStateManager) {
await window.PluginStateManager.loadInstalledPlugins();
}
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to uninstall plugin ${pluginId}`);
}
throw error;
}
},
/** /**
* Whether POST /plugins/update can update this installed-list entry. * Whether POST /plugins/update can update this installed-list entry.
* *
@@ -10,11 +10,6 @@ const PluginStateManager = {
*/ */
installedPlugins: [], installedPlugins: [],
/**
* Current plugin configuration state.
*/
currentConfig: null,
/** /**
* Load installed plugins. * Load installed plugins.
* *
@@ -32,83 +27,6 @@ const PluginStateManager = {
} }
throw error; throw error;
} }
},
/**
* Get plugin by ID.
*
* @param {string} pluginId - Plugin identifier
* @returns {Object|null} Plugin object or null
*/
getPlugin(pluginId) {
return this.installedPlugins.find(p => p.id === pluginId) || null;
},
/**
* Update plugin state.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} updates - State updates
*/
updatePlugin(pluginId, updates) {
const plugin = this.getPlugin(pluginId);
if (plugin) {
Object.assign(plugin, updates);
}
},
/**
* Set plugin enabled state.
*
* @param {string} pluginId - Plugin identifier
* @param {boolean} enabled - Whether plugin is enabled
*/
setPluginEnabled(pluginId, enabled) {
this.updatePlugin(pluginId, { enabled });
},
/**
* Get current plugin configuration.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Plugin configuration
*/
async getPluginConfig(pluginId) {
try {
const config = await window.PluginAPI.getPluginConfig(pluginId);
this.currentConfig = { pluginId, config };
return config;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to load config for ${pluginId}`);
}
throw error;
}
},
/**
* Save plugin configuration.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} config - Configuration data
* @returns {Promise<Object>} Save result
*/
async savePluginConfig(pluginId, config) {
try {
const result = await window.PluginAPI.savePluginConfig(pluginId, config);
// Update local state
if (this.currentConfig && this.currentConfig.pluginId === pluginId) {
this.currentConfig.config = config;
}
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to save config for ${pluginId}`);
}
throw error;
}
} }
}; };
@@ -176,19 +176,19 @@ function showErrorModal(error, context, message, suggestions, docLink) {
} }
// Build modal content // Build modal content
const contextText = context ? `<div class="text-sm text-gray-600 mb-2">${escapeHtml(context)}</div>` : ''; const contextText = context ? `<div class="text-sm text-gray-600 mb-2">${window.LEDEscape.html(context)}</div>` : '';
const suggestionsHtml = suggestions.length > 0 ? ` const suggestionsHtml = suggestions.length > 0 ? `
<div class="mt-4"> <div class="mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-2">Suggested fixes:</h4> <h4 class="text-sm font-semibold text-gray-900 mb-2">Suggested fixes:</h4>
<ul class="list-disc list-inside space-y-1 text-sm text-gray-700"> <ul class="list-disc list-inside space-y-1 text-sm text-gray-700">
${suggestions.map(s => `<li>${escapeHtml(s)}</li>`).join('')} ${suggestions.map(s => `<li>${window.LEDEscape.html(s)}</li>`).join('')}
</ul> </ul>
</div> </div>
` : ''; ` : '';
const docLinkHtml = docLink ? ` const docLinkHtml = docLink ? `
<div class="mt-4"> <div class="mt-4">
<a href="${escapeHtml(docLink)}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 text-sm underline"> <a href="${window.LEDEscape.html(docLink)}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 text-sm underline">
<i class="fas fa-book mr-1" aria-hidden="true"></i>View troubleshooting guide<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;"> (opens in a new tab)</span> <i class="fas fa-book mr-1" aria-hidden="true"></i>View troubleshooting guide<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;"> (opens in a new tab)</span>
</a> </a>
</div> </div>
@@ -198,14 +198,14 @@ function showErrorModal(error, context, message, suggestions, docLink) {
<div class="mt-4"> <div class="mt-4">
<details class="cursor-pointer"> <details class="cursor-pointer">
<summary class="text-sm font-medium text-gray-700 hover:text-gray-900">Technical details</summary> <summary class="text-sm font-medium text-gray-700 hover:text-gray-900">Technical details</summary>
<pre class="mt-2 text-xs bg-gray-100 p-3 rounded overflow-auto max-h-48 text-gray-800">${escapeHtml(error.details)}</pre> <pre class="mt-2 text-xs bg-gray-100 p-3 rounded overflow-auto max-h-48 text-gray-800">${window.LEDEscape.html(error.details)}</pre>
</details> </details>
</div> </div>
` : ''; ` : '';
const errorCodeHtml = error.error_code ? ` const errorCodeHtml = error.error_code ? `
<div class="mt-2 text-xs text-gray-500"> <div class="mt-2 text-xs text-gray-500">
Error code: <code class="bg-gray-100 px-1 py-0.5 rounded">${escapeHtml(error.error_code)}</code> Error code: <code class="bg-gray-100 px-1 py-0.5 rounded">${window.LEDEscape.html(error.error_code)}</code>
</div> </div>
` : ''; ` : '';
@@ -223,7 +223,7 @@ function showErrorModal(error, context, message, suggestions, docLink) {
<h3 id="error-modal-title" class="text-lg leading-6 font-medium text-gray-900">Something went wrong</h3> <h3 id="error-modal-title" class="text-lg leading-6 font-medium text-gray-900">Something went wrong</h3>
<div class="mt-2" id="error-modal-description"> <div class="mt-2" id="error-modal-description">
${contextText} ${contextText}
<p class="text-sm text-gray-500">${escapeHtml(message)}</p> <p class="text-sm text-gray-500">${window.LEDEscape.html(message)}</p>
${errorCodeHtml} ${errorCodeHtml}
${suggestionsHtml} ${suggestionsHtml}
${docLinkHtml} ${docLinkHtml}
@@ -284,8 +284,6 @@ function closeErrorModal() {
} }
} }
function escapeHtml(text) { return window.LEDEscape.html(text); }
/** /**
* Copy error details to clipboard. * Copy error details to clipboard.
* *
@@ -227,7 +227,7 @@
*/ */
window.handleFiles = async function(fieldId, files) { window.handleFiles = async function(fieldId, files) {
const uploadConfig = window.getUploadConfig ? window.getUploadConfig(fieldId) : {}; 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 maxFiles = uploadConfig.max_files || 10;
const maxSizeMB = uploadConfig.max_size_mb || 5; const maxSizeMB = uploadConfig.max_size_mb || 5;
const fileType = uploadConfig.file_type || 'image'; const fileType = uploadConfig.file_type || 'image';
@@ -333,30 +333,24 @@
* @param {string} pluginId - Plugin ID * @param {string} pluginId - Plugin ID
*/ */
window.deleteUploadedImage = async function(fieldId, imageId, pluginId) { 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} fieldId - Field ID
* @param {string} fileId - File ID * @param {string} fileId - File ID
* @param {string} pluginId - Plugin 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) { window.deleteUploadedFile = async function(fieldId, fileId, pluginId) {
const fileTypeLabel = fileType === 'json' ? 'file' : 'image'; if (!confirm('Are you sure you want to delete this image?')) {
if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) {
return; return;
} }
try { try {
const deleteEndpoint = customDeleteEndpoint || (fileType === 'json' ? '/api/v3/plugins/of-the-day/json/delete' : '/api/v3/plugins/assets/delete'); const requestBody = { plugin_id: pluginId, image_id: fileId };
const requestBody = fileType === 'json'
? { file_id: fileId }
: { plugin_id: pluginId, image_id: fileId };
const response = await fetch(deleteEndpoint, { const response = await fetch('/api/v3/plugins/assets/delete', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody) body: JSON.stringify(requestBody)
@@ -381,7 +375,7 @@
window.updateImageList(fieldId, newFiles); window.updateImageList(fieldId, newFiles);
} }
window.showNotification(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success'); window.showNotification('Image deleted successfully', 'success');
} else { } else {
window.showNotification(`Delete failed: ${data.message}`, 'error'); window.showNotification(`Delete failed: ${data.message}`, 'error');
} }
@@ -392,14 +386,13 @@
}; };
/** /**
* Get upload configuration for a file upload field. * Get upload configuration for a file upload field, read from the data
* Priority: 1) data attributes on the file input element (server-rendered), * attributes the server renders on the file input or its drop zone.
* 2) schema lookup via window.currentPluginConfig (client-rendered).
* @param {string} fieldId - Field ID * @param {string} fieldId - Field ID
* @returns {Object} Upload configuration * @returns {Object} Upload configuration
*/ */
window.getUploadConfig = function(fieldId) { 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). // the drop zone wrapper (which survives progress-helper re-renders).
// Accept any upload-related data attribute — not just pluginId. // Accept any upload-related data attribute — not just pluginId.
const configSource = getConfigSourceElement(fieldId); const configSource = getConfigSourceElement(fieldId);
@@ -417,45 +410,6 @@
return config; 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 {}; return {};
}; };
@@ -501,7 +455,7 @@
if (!imageList) return; if (!imageList) return;
const uploadConfig = window.getUploadConfig(fieldId); 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 openEditor = imageList.querySelector('[id^="schedule_"]:not(.hidden)');
const openScheduleId = openEditor ? openEditor.id.slice('schedule_'.length) : null; const openScheduleId = openEditor ? openEditor.id.slice('schedule_'.length) : null;
@@ -20,9 +20,12 @@
* Load a plugin-specific widget * Load a plugin-specific widget
* @param {string} pluginId - Plugin ID * @param {string} pluginId - Plugin ID
* @param {string} widgetName - Widget name * @param {string} widgetName - Widget name
* @param {string} [version] - Plugin version, appended as ?v= so an
* updated plugin's widget is not served from the year-long
* immutable cache /static/ responses get (app.py)
* @returns {Promise<void>} Promise that resolves when widget is loaded * @returns {Promise<void>} Promise that resolves when widget is loaded
*/ */
window.LEDMatrixWidgets.loadPluginWidget = async function(pluginId, widgetName) { window.LEDMatrixWidgets.loadPluginWidget = async function(pluginId, widgetName, version) {
if (!pluginId || !widgetName) { if (!pluginId || !widgetName) {
throw new Error('Plugin ID and widget name are required'); 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 // The one route that serves plugin widgets (serve_plugin_widget in
// blueprints/pages_v3.py); nothing is served under /plugins/<id>/ or // blueprints/pages_v3.py); nothing is served under /plugins/<id>/ or
// /static/plugins/<id>/, so trying those only added two failed imports. // /static/plugins/<id>/, so trying those only added two failed imports.
const versionQuery = version ? `?v=${encodeURIComponent(version)}` : '';
const possiblePaths = [ const possiblePaths = [
`/static/plugin-widgets/${pluginId}/${widgetName}.js` `/static/plugin-widgets/${pluginId}/${widgetName}.js${versionQuery}`
]; ];
let lastError = null; let lastError = null;
@@ -69,9 +73,10 @@
* Called automatically when a widget is referenced in a plugin's config schema * Called automatically when a widget is referenced in a plugin's config schema
* @param {string} widgetName - Widget name * @param {string} widgetName - Widget name
* @param {string} pluginId - Plugin ID (optional, for plugin-specific widgets) * @param {string} pluginId - Plugin ID (optional, for plugin-specific widgets)
* @param {string} [version] - Plugin version (optional, see loadPluginWidget)
* @returns {Promise<boolean>} True if widget is available (either already registered or successfully loaded) * @returns {Promise<boolean>} True if widget is available (either already registered or successfully loaded)
*/ */
window.LEDMatrixWidgets.ensureWidget = async function(widgetName, pluginId) { window.LEDMatrixWidgets.ensureWidget = async function(widgetName, pluginId, version) {
// Check if widget is already registered // Check if widget is already registered
if (this.has(widgetName)) { if (this.has(widgetName)) {
return true; return true;
@@ -80,7 +85,7 @@
// If plugin ID provided, try to load as plugin widget // If plugin ID provided, try to load as plugin widget
if (pluginId) { if (pluginId) {
try { try {
await this.loadPluginWidget(pluginId, widgetName); await this.loadPluginWidget(pluginId, widgetName, version);
return this.has(widgetName); return this.has(widgetName);
} catch (error) { } catch (error) {
console.warn(`[PluginWidgetLoader] Could not load widget ${widgetName} from plugin ${pluginId}:`, error); console.warn(`[PluginWidgetLoader] Could not load widget ${widgetName} from plugin ${pluginId}:`, error);
@@ -91,37 +96,4 @@
// Widget not found // Widget not found
return false; return false;
}; };
/**
* Load all widgets specified in plugin manifest
* @param {string} pluginId - Plugin ID
* @param {Object} manifest - Plugin manifest object
* @returns {Promise<Array<string>>} Array of successfully loaded widget names
*/
window.LEDMatrixWidgets.loadPluginWidgetsFromManifest = async function(pluginId, manifest) {
if (!manifest || !manifest.widgets || !Array.isArray(manifest.widgets)) {
return [];
}
const loadedWidgets = [];
for (const widgetDef of manifest.widgets) {
const widgetName = widgetDef.name || widgetDef.script?.replace(/\.js$/, '');
if (!widgetName) {
console.warn(`[PluginWidgetLoader] Invalid widget definition in manifest:`, widgetDef);
continue;
}
try {
await this.loadPluginWidget(pluginId, widgetName);
if (this.has(widgetName)) {
loadedWidgets.push(widgetName);
}
} catch (error) {
console.error(`[PluginWidgetLoader] Failed to load widget ${widgetName} from plugin ${pluginId}:`, error);
}
}
return loadedWidgets;
};
})(); })();
+26 -156
View File
@@ -45,7 +45,7 @@
* installFromCustomRegistry, removeSavedRepository, executePluginAction, * installFromCustomRegistry, removeSavedRepository, executePluginAction,
* openOnDemandModal, requestOnDemandStop, loadOnDemandStatus, * openOnDemandModal, requestOnDemandStop, loadOnDemandStatus,
* installStarlarkApp, installPixlet, the GitHub token functions, * installStarlarkApp, installPixlet, the GitHub token functions,
* window.installedPlugins and window.currentPluginConfig. * and window.installedPlugins.
*/ */
// ─── LocalStorage Safety Wrappers ──────────────────────────────────────────── // ─── LocalStorage Safety Wrappers ────────────────────────────────────────────
@@ -215,7 +215,7 @@ window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
if (plugin) { if (plugin) {
plugin.enabled = !enabled; plugin.enabled = !enabled;
} }
window.pluginManager.loadInstalledPlugins(); window.pluginManager.loadInstalledPlugins().catch(() => {});
} }
// Clear token and re-enable UI // Clear token and re-enable UI
@@ -243,7 +243,7 @@ window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
if (plugin) { if (plugin) {
plugin.enabled = !enabled; plugin.enabled = !enabled;
} }
window.pluginManager.loadInstalledPlugins(); window.pluginManager.loadInstalledPlugins().catch(() => {});
// Clear token and re-enable UI // Clear token and re-enable UI
delete window._pluginToggleRequests[pluginId]; delete window._pluginToggleRequests[pluginId];
@@ -452,10 +452,9 @@ window.handleGitHubPluginInstall = function() {
} }
urlInput.value = ''; urlInput.value = '';
// Show notification if available
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success'); showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
setTimeout(() => window.pluginManager.loadInstalledPlugins(true), 1000); setTimeout(() => window.pluginManager.loadInstalledPlugins(true).catch(() => {}), 1000);
} else { } else {
if (statusDiv) { if (statusDiv) {
statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${window.LEDEscape.html(data.message || 'Installation failed')}</span>`; statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${window.LEDEscape.html(data.message || 'Installation failed')}</span>`;
@@ -609,7 +608,6 @@ window.checkGitHubAuthStatus = function checkGitHubAuthStatus() {
// Local variables for this instance // Local variables for this instance
let installedPlugins = []; let installedPlugins = [];
window.currentPluginConfig = null;
let pluginStoreCache = null; // Cache for plugin store to speed up subsequent loads let pluginStoreCache = null; // Cache for plugin store to speed up subsequent loads
let cacheTimestamp = null; let cacheTimestamp = null;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds 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) { function loadInstalledPlugins(forceRefresh = false) {
// Return cached data if valid and not forcing refresh // Return cached data if valid and not forcing refresh
if (!forceRefresh && pluginLoadCache.isValid()) { if (!forceRefresh && pluginLoadCache.isValid()) {
@@ -1381,7 +1382,7 @@ function handlePluginAction(event) {
.then(data => { .then(data => {
if (data.status === 'success') { if (data.status === 'success') {
showNotification('Starlark app uninstalled', 'success'); showNotification('Starlark app uninstalled', 'success');
loadInstalledPlugins(true); loadInstalledPlugins(true).catch(() => {});
} else { } else {
alert('Uninstall failed: ' + (data.message || 'Unknown error')); alert('Uninstall failed: ' + (data.message || 'Unknown error'));
} }
@@ -1544,19 +1545,8 @@ function runUpdateAllPlugins() {
return; return;
} }
// Counted by install_manager.js from each answer's update_status: // Counted by install_manager.js from each answer's update_status:
// a no-op update is "already up to date", not "updated". A cached // a no-op update is "already up to date", not "updated".
// install_manager.js from before that helper gets a plain count. const summary = window.PluginInstallManager.summarizeUpdateResults(results);
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'
};
})();
showNotification(summary.text, summary.type); showNotification(summary.text, summary.type);
}) })
.catch(error => { .catch(error => {
@@ -1887,158 +1877,38 @@ function closeOnDemandModalOnBackdrop(event) {
} }
// Generic Plugin Action Handler // Generic Plugin Action Handler
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) { window.executePluginAction = function(actionId, actionIndex, pluginId) {
debugLog('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam);
// Construct button ID first (we have actionId and actionIndex)
const actionIdFull = `action-${actionId}-${actionIndex}`; const actionIdFull = `action-${actionId}-${actionIndex}`;
const statusId = `action-status-${actionId}-${actionIndex}`; const statusId = `action-status-${actionId}-${actionIndex}`;
const btn = document.getElementById(actionIdFull); const btn = document.getElementById(actionIdFull);
const statusDiv = document.getElementById(statusId); const statusDiv = document.getElementById(statusId);
// Get plugin ID from multiple sources with comprehensive fallback logic // The action button (plugin_config.html) always passes its plugin's id.
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
if (!pluginId) { if (!pluginId) {
pluginId = currentPluginConfig?.pluginId; console.error('executePluginAction called without a plugin ID. actionId:', actionId, 'actionIndex:', actionIndex);
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);
showNotification('Unable to determine plugin ID. Please refresh the page.', 'error'); showNotification('Unable to determine plugin ID. Please refresh the page.', 'error');
return; return;
} }
debugLog('[DEBUG] executePluginAction - Final pluginId:', pluginId, 'actionId:', actionId, 'actionIndex:', actionIndex);
if (!btn || !statusDiv) { if (!btn || !statusDiv) {
console.error(`Action elements not found: ${actionIdFull}`); console.error(`Action elements not found: ${actionIdFull}`);
return; return;
} }
// Get action definition - try currentPluginConfig first, then fetch from API let action = null;
let action = currentPluginConfig?.webUiActions?.[actionIndex]; if (window.installedPlugins) {
const plugin = window.installedPlugins.find(p => p.id === pluginId);
if (!action) { if (plugin && plugin.web_ui_actions) {
// Try to get from installed plugins action = plugin.web_ui_actions[actionIndex];
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) { if (!action) {
console.error(`Action not found: ${actionId} for plugin ${pluginId}`); 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'); showNotification(`Action ${actionId} not found. Please refresh the page.`, 'error');
return; return;
} }
debugLog('[DEBUG] Found action:', action);
// Check if we're in step 2 (completing OAuth flow) // Check if we're in step 2 (completing OAuth flow)
if (btn.dataset.step === '2') { if (btn.dataset.step === '2') {
const redirectUrl = prompt(action.step2_prompt || 'Please paste the full redirect URL:'); 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 onComplete = options.onComplete || (() => handleUninstallSuccess(pluginId));
const onFailed = options.onFailed || ((errorMsg) => { const onFailed = options.onFailed || ((errorMsg) => {
showNotification(errorMsg || `Operation failed for ${pluginName}`, 'error'); showNotification(errorMsg || `Operation failed for ${pluginName}`, 'error');
setTimeout(() => loadInstalledPlugins(), 1000); setTimeout(() => loadInstalledPlugins().catch(() => {}), 1000);
}); });
const onTimeout = options.onTimeout || (() => { const onTimeout = options.onTimeout || (() => {
showNotification(`Operation timed out for ${pluginName}`, 'error'); showNotification(`Operation timed out for ${pluginName}`, 'error');
setTimeout(() => loadInstalledPlugins(), 1000); setTimeout(() => loadInstalledPlugins().catch(() => {}), 1000);
}); });
if (attempt >= maxAttempts) { if (attempt >= maxAttempts) {
@@ -2250,7 +2120,7 @@ function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
console.error('Error polling operation status:', error); console.error('Error polling operation status:', error);
// On error, refresh plugin list to see actual state // On error, refresh plugin list to see actual state
setTimeout(() => { setTimeout(() => {
loadInstalledPlugins(); loadInstalledPlugins().catch(() => {});
}, 1000); }, 1000);
}); });
} }
@@ -2268,7 +2138,7 @@ function handleUninstallSuccess(pluginId) {
// refreshInstalledPlugins() bypasses them. // refreshInstalledPlugins() bypasses them.
pluginLoadCache.invalidate(); pluginLoadCache.invalidate();
setTimeout(() => { setTimeout(() => {
refreshInstalledPlugins(); refreshInstalledPlugins().catch(() => {});
}, 1000); }, 1000);
} }
@@ -2284,7 +2154,7 @@ function refreshPlugins() {
refreshInstalledPlugins().then(() => { refreshInstalledPlugins().then(() => {
searchPluginStore(true); searchPluginStore(true);
showNotification('Plugins refreshed with latest metadata from GitHub', 'success'); showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
}); }, () => {});
} }
function restartDisplay() { function restartDisplay() {
@@ -2637,7 +2507,7 @@ window.installPlugin = function(pluginId, branch = null) {
} }
}); });
// Refresh installed plugins list, then re-render store to update badges // Refresh installed plugins list, then re-render store to update badges
loadInstalledPlugins(); loadInstalledPlugins().catch(() => {});
setTimeout(() => applyStoreFiltersAndSort(true), 500); setTimeout(() => applyStoreFiltersAndSort(true), 500);
} }
@@ -2696,7 +2566,7 @@ window.installFromCustomRegistry = function(pluginId, registryUrl, pluginPath, b
if (data.status === 'success') { if (data.status === 'success') {
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success'); showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
// Refresh installed plugins and re-render custom registry // Refresh installed plugins and re-render custom registry
loadInstalledPlugins(); loadInstalledPlugins().catch(() => {});
// Re-render custom registry to update install buttons // Re-render custom registry to update install buttons
const registryUrlInput = document.getElementById('github-registry-url'); const registryUrlInput = document.getElementById('github-registry-url');
if (registryUrlInput && registryUrlInput.value.trim()) { if (registryUrlInput && registryUrlInput.value.trim()) {
@@ -2890,7 +2760,7 @@ function attachInstallButtonHandler() {
// Refresh installed plugins list // Refresh installed plugins list
setTimeout(() => { setTimeout(() => {
loadInstalledPlugins(); loadInstalledPlugins().catch(() => {});
}, 1000); }, 1000);
} else { } else {
if (pluginStatusDiv) { if (pluginStatusDiv) {
+5 -5
View File
@@ -275,17 +275,17 @@
<!-- Alpine.js app function - defined early so it's available when Alpine initializes --> <!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script src="{{ url_for('static', filename='v3/js/app-early.js') }}"></script> <script src="{{ url_for('static', filename='v3/js/app-early.js') }}"></script>
<!-- App shell (full app(), Alpine stores, SSE streams). Deferred so it
doesn't block rendering, and placed before Alpine: deferred scripts run
in document order, and its alpine:init listener must be registered
before Alpine starts. -->
<script defer src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
<!-- Alpine.js for reactive components. <!-- Alpine.js for reactive components.
Load the local copy first (always works, no CDN round-trip, no AP-mode Load the local copy first (always works, no CDN round-trip, no AP-mode
branch needed). `defer` on an HTML-parsed <script> is honored and runs branch needed). `defer` on an HTML-parsed <script> is honored and runs
after DOM parse but before DOMContentLoaded, which is exactly what after DOM parse but before DOMContentLoaded, which is exactly what
Alpine wants — so no deferLoadingAlpine gymnastics are needed. Alpine wants — so no deferLoadingAlpine gymnastics are needed.
The inline rescue below only fires if the local file is missing. --> The inline rescue below only fires if the local file is missing. -->
<!-- App shell (full app(), Alpine stores, SSE streams). Deferred so it
doesn't block rendering, and placed before Alpine: deferred scripts run
in document order, and its alpine:init listener must be registered
before Alpine starts. -->
<script defer src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
<script defer src="{{ url_for('static', filename='v3/js/alpinejs.min.js') }}"></script> <script defer src="{{ url_for('static', filename='v3/js/alpinejs.min.js') }}"></script>
<script> <script>
// Rescue: if the local Alpine didn't load for any reason, pull the CDN // Rescue: if the local Alpine didn't load for any reason, pull the CDN
@@ -1044,11 +1044,7 @@ document.getElementById('brightness').addEventListener('input', function() {
} }
function startSyncPolling() { function startSyncPolling() {
updateSyncUI(); updateSyncUI();
if (window.LEDVisibility) { window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
} else if (!window.syncStatusInterval) {
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -88,9 +88,6 @@
</div> </div>
<script> <script>
(function() { (function() {
// Track if already initialized to prevent re-render
if (window.__timezoneWidgetInitialized) return;
function initTimezoneWidget() { function initTimezoneWidget() {
if (!window.LEDMatrixWidgets) { setTimeout(initTimezoneWidget, 50); return; } if (!window.LEDMatrixWidgets) { setTimeout(initTimezoneWidget, 50); return; }
var widget = window.LEDMatrixWidgets.get('timezone-selector'); var widget = window.LEDMatrixWidgets.get('timezone-selector');
@@ -107,7 +104,6 @@
fieldId: 'timezone', fieldId: 'timezone',
name: 'timezone' name: 'timezone'
}); });
window.__timezoneWidgetInitialized = true;
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTimezoneWidget); document.addEventListener('DOMContentLoaded', initTimezoneWidget);
@@ -264,11 +264,7 @@ window._filteredLogs = [];
} }
stopRealtimeLogs(); stopRealtimeLogs();
} }
if (window.LEDVisibility) { window.LEDVisibility.onActive('logs', startLogsActivity, stopLogsActivity);
window.LEDVisibility.onActive('logs', startLogsActivity, stopLogsActivity);
} else {
startLogsActivity();
}
// Handle window resize for responsive height. Added once: this script // Handle window resize for responsive height. Added once: this script
// re-runs on every partial load, and the handler reads window globals. // re-runs on every partial load, and the handler reads window globals.
@@ -1101,6 +1101,7 @@
(function() { (function() {
var WIDGET = {{ str_widget|tojson|safe }}; var WIDGET = {{ str_widget|tojson|safe }};
var PLUGIN = {{ plugin_id|tojson|safe }}; var PLUGIN = {{ plugin_id|tojson|safe }};
var VERSION = {{ ((plugin.version if plugin is defined and plugin else '') or '')|string|tojson|safe }};
function initPluginWidget() { function initPluginWidget() {
if (!window.LEDMatrixWidgets || !window.LEDMatrixWidgets.ensureWidget) { if (!window.LEDMatrixWidgets || !window.LEDMatrixWidgets.ensureWidget) {
setTimeout(initPluginWidget, 50); setTimeout(initPluginWidget, 50);
@@ -1109,7 +1110,7 @@
var container = document.getElementById('{{ field_id }}_container'); var container = document.getElementById('{{ field_id }}_container');
var fallback = document.getElementById('{{ field_id }}'); var fallback = document.getElementById('{{ field_id }}');
if (!container) { return; } if (!container) { return; }
window.LEDMatrixWidgets.ensureWidget(WIDGET, PLUGIN).then(function(available) { window.LEDMatrixWidgets.ensureWidget(WIDGET, PLUGIN, VERSION).then(function(available) {
if (!available) { container.remove(); return; } if (!available) { container.remove(); return; }
var widget = window.LEDMatrixWidgets.get(WIDGET); var widget = window.LEDMatrixWidgets.get(WIDGET);
if (!widget || typeof widget.render !== 'function') { container.remove(); return; } if (!widget || typeof widget.render !== 'function') { container.remove(); return; }