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