mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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) {
|
if (!pluginId) {
|
||||||
// Try to extract from ID like "plugin-config-{pluginId}"
|
console.error('executePluginAction called without a plugin ID. actionId:', actionId, 'actionIndex:', actionIndex);
|
||||||
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) {
|
|
||||||
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);
|
|
||||||
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 (!action) {
|
|
||||||
// Try to get from installed plugins
|
|
||||||
if (window.installedPlugins) {
|
if (window.installedPlugins) {
|
||||||
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
||||||
if (plugin && plugin.web_ui_actions) {
|
if (plugin && plugin.web_ui_actions) {
|
||||||
action = plugin.web_ui_actions[actionIndex];
|
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) {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user