mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(web): keep Cache and Logs helpers out of each other's way Both partials declared top-level showError and escapeHtml. Their scripts run at global scope after every HTMX swap, so whichever tab was opened last owned window.showError, and a Cache failure after visiting Logs rendered into the Logs panel (and the other way round). Each script is now an IIFE; Cache still exports deleteCacheFile for its row buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): make the HTMX-failure fallbacks for tab panels actually run - The "HTMX never loaded" fallback read appElement.__x.$data, which is Alpine 2. The page ships Alpine 3, so the check was always false and the Overview never loaded without HTMX. It now reads Alpine.$data(). - The Overview and WiFi panels used hx-on::htmx:response-error, which htmx expands to "htmx:htmx:response-error", an event that never fires. - loadTabContent sent requests with <body> as the source, so htmx fired its events on <body> and no panel's hx-on handler ran at all. The panel is now the source. htmx also resolves its promise on a 4xx/5xx, and the panel was stamped data-loaded anyway, leaving a skeleton that never retried; it is now stamped only when no responseError fired. loadPluginsDirect, loadOverviewDirect and loadWifiDirect are merged into one window.loadPartialDirect(id, url), which also runs the partial's inline scripts before Alpine sees the markup (as htmx-config.js does on htmx:afterSwap). The ~10 s "htmx never arrived" path in loadTabContent uses it for every tab instead of four hard-coded ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): store and registry failures no longer wipe the Plugin Manager showError replaced the whole #plugins-content with an error message, so one failed store search, custom-registry install or saved-repository call took the installed list, the store and every control with it, with no way back short of reloading the tab. Those failures are now error notifications. The full-panel message is kept only for a first load of the installed list that failed (nothing to show yet); a failed refresh of an already-rendered list is a notification too. showSuccess's fallback branch, which wrote the message into innerHTML unescaped, is gone: showNotification always exists. The "Please try refreshing your browser" hint tested for the text "Failed to Fetch", which no browser produces (Chrome says "Failed to fetch", Firefox "NetworkError..."), so it never appeared. It now keys on the failure itself: a TypeError from fetch(), or PluginAPI's NETWORK_ERROR wrapper around one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): escape plugin action and install output on every path executePluginAction escaped data.message and data.output when an action failed but put data.message straight into innerHTML when it succeeded, and set the OAuth step-2 button's innerHTML from the manifest's step2_button_text. Plugin actions run plugin code, so that is plugin- or server-controlled markup in the page. Both paths now escape, and the button label is set with textContent. The same pattern sat in the install-from-GitHub-URL status lines (plugin_id, the server's message, and error.message, which can echo a repository URL) and the custom-registry load error; those are escaped too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): file-upload widget owns the image list and schedule editor plugins_manager.js loads after the widget bundle, so its older copies of deleteUploadedFile, updateImageList, hideUploadProgress, formatDate, openImageSchedule, toggleImageScheduleEnabled, updateImageSchedule{Mode, Time,Day} and updateCheckboxGroupData replaced the widget's. They are deleted; the widget files are the only definitions. Before switching over, the two sets were diffed and fixed so nothing regresses: - The old copy labelled the schedule/delete buttons for screen readers and lazy-loaded thumbnails; the widget now does both. - The schedule button did nothing on a card rendered by plugin_config.html whenever the image id is a UUID (every upload): the template turns "-" into "_" in the editor's id, and neither JS copy did. Both now use the template's rule. - The widget's "keep the open editor open" copied the editor's innerHTML into the new list. That dropped its event listeners and showed the old values, so after the first change the editor looked live but ignored input. A schedule edit now saves to the hidden input and updates the card's summary in place without re-rendering the list; a list re-render (upload, delete) rebuilds an open editor from the data. Editor controls are routed by one delegated change listener, so there are no per-element listeners to lose. - The old deleteUploadedFile had a JSON branch that removed a #file_<id> element and skipped the re-render. No template or script renders such an element, and JSON uploads are listed through updateImageList like images, so re-rendering (the widget's behaviour) is the consistent one; the branch was not carried over. - The template always renders the summary line (".image-schedule-summary", "Always shown" when unscheduled) so an edit has a line to update. The inline-handler test evaluated plugins_manager.js's updateImageList; test_file_upload_widget.js now covers the widget's list and editor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete the unused handleCredentialsUpload Its last caller went when plugin_config.html switched credential uploads to the file-upload widget's handleSingleFileSelect. Nothing in the web UI, the tests or the plugin monorepo references it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete dead and shadowed front-end code Nothing calls any of these (checked across web_interface/, test/ and the ledmatrix-plugins monorepo, including hx-*/x-*/onclick attributes): - app-shell.js: the Alpine methods refreshPlugins (it called a nonexistent this.searchPluginStore), loadPluginConfig, savePluginConfig, getSchemaPropertyType, escapeCssSelector, formatCommitInfo and formatDateInfo, and the top-level copies of savePluginConfig, getSchemaPropertyType, escapeCssSelector, formatCommitInfo, formatDateInfo and togglePluginFromTab. Plugin config forms save through hx-post in plugin_config.html. - window.reconnectSSE (app-shell.js); window.updateArrayTableAddButtonState (array-table.js). - toggleNestedSection, defined twice (app-shell.js and plugins_manager.js) and called from nowhere. - plugins_manager.js: the window.initializePlugins wrapper around an IIFE-local origInit that was always undefined, and __pluginDomReady, which was written but never read. - display.html's fixInvalidNumberInputs fallback: app-shell.js defines it before any partial loads. - base.html's window.loadCodeMirror and the two CodeMirror stylesheet preloads, and the .CodeMirror rules in plugins.html. The raw JSON editor is a plain textarea. Also deleted: app-shell.js definitions that a later script always replaced, so they never ran: executePluginAction (plugins_manager.js assigns its own), uninstallPlugin and its pollUninstallOperation (plugins_manager.js), and updateAllPlugins (install_manager.js). vendor/codemirror stays: test/test_web_smoke.py still requests codemirror.min.js as a sample static asset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): call showNotification without checking it exists app-shell.js defines window.showNotification (a stand-in that queues until the notification widget loads) and base.html runs it, deferred, before every other script that notifies: app.js, the utilities, the widget bundle, plugins_manager.js, and all partials, which HTMX loads after the page. The 81 `typeof showNotification === 'function'` / `!== 'undefined'` checks, the `window.showNotification || console.log` and `|| alert` fallbacks, and their else branches (alert(), console output, and schedule.html's own hand-built toast) could never take the fallback path. They are removed, as is fonts.html's second copy of the queueing stand-in. The stand-in in app-shell.js keeps its guard (it must not replace the widget's implementation if load order ever changes), and BaseWidget's public notify()/getNotificationFunction() keep their shape for widgets that plugins ship. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): one HTML escaper, window.LEDEscape About 30 files each carried their own escapeHtml / escapeAttr / escHtml / _esc / escapeJs. They disagreed: several (notification.js, display.html's escapeHtml, operation_history.html, the app() stub) did not escape quotes, google-calendar-picker.js and tools.html's escHtml left ' alone, and some turned 0 into ''. Most were fine only because the quote-safe widget copies were preferred at runtime. window.LEDEscape now lives at the top of app-early.js, a blocking script in <head>, so it exists before any other script runs: html(v) & < > " ' as entities, null/undefined as '' attr(v) the same, for call sites that want to say "attribute" jsStringAttr(v) a JS string literal safe inside an inline handler Every former copy is now a one-line name for it (kept so call sites do not change), widgets included, with no fallback. plugins_manager.js loses its four escapeJs wrappers (callers use jsStringAttr), the duplicate escapeAttr and escapeHtml inside renderInstalledCards and renderCustomRegistryPlugins, and the window.escapeHtml / window.escapeAttribute exports, which nothing read. addArrayObjectItem's fallback markup (with a sixth hand-written escape chain) is gone too: window.renderArrayObjectItem is defined earlier in the same file, so the fallback could not run. The unused escapeHtml methods on the Alpine app (app-early.js stub and app-shell.js) are deleted. test_html_escaping.js now runs LEDEscape and every remaining name for it, and fails if a hand-rolled escaper reappears anywhere in web_interface/. Suites that evaluate slices of plugins_manager.js or widget files load LEDEscape from app-early.js through test/js/led_escape.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): stop htmx re-running partial scripts after every tab load htmx-config.js runs each swapped-in <script> itself on htmx:afterSwap and meant to turn htmx's own script handling off with htmx.config.allowScriptTags = false. It did that once, while setting up, but base.html loads htmx with a dynamic <script>, so htmx was not defined yet and the setting never applied. On every tab load htmx then tried to run each script again in its settle phase, found it already replaced (no parent node) and threw "Cannot read properties of null (reading 'insertBefore')" into the console, which also skipped the rest of that swap's settle tasks. The setting is now applied in the afterSwap handler, which always runs after htmx exists and before htmx settles the same swap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): show "--" for a system stat the server could not read The stats stream and /system/status now send null for a metric they cannot read (cpu_temp off a Pi, for one) instead of 0. updateSystemStats built the header and Overview text as value + unit, so a null showed as "null°C". CPU, memory and temperature, in the header and on the Overview, now render "--" plus the unit for null or a missing field -- the same placeholder the page starts with, and what tools.html already shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): one Alpine accessor and one plugin-list signal window.getApp() (app-early.js) returns the root <body x-data="app()"> component through Alpine's public Alpine.$data, or null before Alpine has initialised it. It replaces the private el._x_dataStack[0] reads in app.js, app-early.js, app-shell.js, settings-search.js, overview.html and plugins_manager.js, the three local getAppComponent/appData/getAppData copies, and the Alpine 2 el.__x.$data fallbacks, which Alpine 3 never provides. Publishing the installed-plugin list: one load set window.installedPlugins and dispatched pluginsUpdated twice (loadInstalledPlugins, then renderInstalledPlugins), then wrote into the Alpine component through _x_dataStack[0] and called its updatePluginTabs() directly, and app-early.js's global listener set window.installedPlugins a third time and called updatePluginTabs() again. Now renderInstalledPlugins is the one publisher: it sets window.installedPlugins and dispatches pluginsUpdated once, and the full app()'s listener (app-shell.js) is the receiver. The app-early.js listener only builds the tab row while the app is not the full implementation yet. The "grid not loaded yet" case is a normal state (Plugin Manager tab not opened), so it logs through pluginLog instead of console.warn. updatePluginTabs had a "Debounce" comment and clearTimeout over a timer nothing ever set, and two identical branches; it now just calls _doUpdatePluginTabs (app-early.js detects the full implementation by that name in its source, which the new comment says). app()'s unused baseComponent lookup is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): reload the plugin list after installs and failed toggles Several callers refreshed the installed list with if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); else if (typeof window.loadInstalledPlugins === 'function') ... but loadInstalledPlugins is local to the plugin-manager IIFE and window.loadInstalledPlugins is never defined, so from outside that IIFE both tests were false and nothing reloaded: - A failed plugin toggle left the switch drawn in the new state while the data said the old one. It now re-renders from the reverted data. The optimistic in-place edit also has to forget the grid's last-rendered markup, or setGridHtmlIfChanged sees identical HTML and skips the revert. A successful toggle still keeps the switch (and focus) as drawn. - Installing from a GitHub URL (the early handleGitHubPluginInstall), installing or uploading a Starlark app, and toggling a Starlark app on its config tab never refreshed the list, so the new app had no tab or Installed badge until the page was reloaded. They now force a reload through window.pluginManager.loadInstalledPlugins(true), and the Starlark grid redraws when that finishes instead of after a fixed 500 ms. - The Starlark uninstall inside the IIFE reloaded from the 3 s cache, which could still hold the app; it now forces a reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): route debug output through debugLog base.html defines window.debugLog, gated on localStorage.pluginDebug. plugins_manager.js read the same key twice more into its own flags (_PLUGIN_DEBUG_EARLY, and PLUGIN_DEBUG behind a pluginLog() wrapper), and api_client.js's RequestThrottler had a separate `debug` property with a setDebug() that nothing called. All of it now goes through debugLog. The "functions defined" dumps with their ✓ lines, and two per-plugin "enabled=" loops that ran on every render, are dropped; "[PLUGINS STUB]" labels on code that has not been a stub for a long time read "[PLUGINS]". Ungated console.log calls that announced normal events on every page load or action (settings search and tooltips registering, every toast repeated to the console, the schedule pickers initialising, widget registry unregister/clear) go through debugLog too. What remains on console.log is the widget registry's on-demand LEDMatrixWidgets.debug() dump and BaseWidget.notify's no-notifier fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): drop waits and guards that could never fire - handlePluginAction polled up to 10 x 50 ms for window.togglePlugin, configurePlugin, updatePlugin and uninstallPlugin before calling them. All four are defined when the scripts load, before any card can be clicked, so the poll always succeeded at once; it now calls them. The long thinking-aloud comment over the toggle state is replaced by two lines on why the stored state, not the checkbox, decides. - initializePlugins checked typeof on setupGitHubInstallHandlers and applyStoreFiltersAndSort, function declarations in the same IIFE, and wrapped window.checkGitHubAuthStatus(), which returns a promise with its own .catch, in try/catch. - searchPluginStore wrapped each "#store-count" update (a getElementById and an innerHTML assignment) in try/catch four times; one setStoreCount() helper does it. The store's post-render re-attach of the GitHub token handler dropped its try/catch and existence checks for the same reason. - The load-time fallback outside the IIFE tested typeof initializePluginPageWhenReady, which is IIFE-local and so always undefined there; it calls window.initPluginsPage directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete two unused plugin-manager helpers stopOnDemand (IIFE-local; the page's stop button calls window.stopOnDemand from app-shell.js) and debounce had no callers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): document the plugin-config handlers templates call validatePluginConfigForm, handleConfigSave, handleToggleResponse, handlePluginUpdate and refreshPluginConfig each get a JSDoc naming the attribute in partials/plugin_config.html that calls it and what the return value means (only validatePluginConfigForm's matters: false cancels the submit). - The `if (!window.__pluginConfigHandlersInitialized)` wrapper is gone: app-shell.js runs once per page, so it was never false. The block is dedented one level; `git diff -w` shows the real change. - The three handlers read xhr.responseJSON first. XMLHttpRequest has no such property (it is jQuery's), so that branch never ran; one xhrJson(xhr) helper parses responseText for all of them, with the same fallbacks as before. - runPluginOnDemand and stopOnDemand checked that plugins_manager.js's openOnDemandModal/requestOnDemandStop exist; plugins_manager.js is on every page, so they call them. - fixInvalidNumberInputs had a stray "Notification helper function" comment on top of its own; a leftover "section toggle ... duplicate definition removed" note is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): one toast per save, and a failed durations save says so app.js's global htmx:afterRequest listener showed the server's message for every htmx request, and every form and button that posts through htmx (plugin config save/toggle/update, Display, Durations, General, Schedule, Dim schedule, the Overview actions) also reports its own result from hx-on after-request. Each save showed two toasts. The global listener now stays quiet for a request whose element, or its form, has its own after-request handler. That exposed the Rotation & Durations form's handler, which read xhr.responseJSON: XMLHttpRequest has no such property, so it always said "Durations saved" in green, even when the save failed (the global toast had been the only place the error showed). display.html already had a correct version (2xx only counts as saved; the server's message wins; its status may refine success but never overturn failure). That is now window.showSaveResult(xhr, savedText, failedText) in app.js, used by the Display, Durations and General forms; General's inline copy of the same logic is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(web): file headers and comments that say what the code does now - plugins_manager.js, app-shell.js, app.js and app-early.js open with a header: what the file owns, how base.html loads it and in what order relative to the others, and the globals it defines. app-early.js's app() stub also says why it exists and that, with app-shell.js now loaded before Alpine, it does not run in practice. - base.html's note on plugins_manager.js said it must load last to win over same-named functions in app.js/app-shell.js; there are none left, so it now gives the real reason (it uses everything loaded before it). - Change-narration and "already defined at the top, no need to redefine" notes are gone or rewritten as present-tense reasons; comments that were wrong are fixed ("Toggle password visibility" over the function that opens the token panel, "Insert before the closing </nav>" over an appendChild, "(from v2)", the export note that still listed escapeHtml). About forty comments that restated the line below them are removed, and a second window.currentPluginConfig = null outside the IIFE is dropped (the IIFE sets it). - The file-upload, checkbox-group and custom-feeds widgets' render() stubs say plainly that the widget is rendered server-side, instead of "for now" / "placeholder for future client-side rendering". test_plugin_action_delegation.js sliced the source up to one of the removed notes; it now ends the slice at the next section header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep the escapeHtml/escapeAttribute globals for plugin pages 6da77363 removed window.escapeHtml and window.escapeAttribute because nothing in core or the plugin monorepo read them. Plugin web UIs served through serve_plugin_web_ui and third-party plugin pages may still call them, so they come back as aliases of window.LEDEscape.html and .attr, defined in app-early.js before any other script runs. test_html_escaping.js checks the aliases exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): web-frontend Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): encode the image thumbnail path; match script tags case-insensitively CodeQL flagged the upload widget building an <img> src from a stored path, and the escaper test extracting inline scripts with a case-sensitive regex. Each path segment is now URL-encoded (still a same-origin path, and correct for names with spaces or * fix(web): clear Codacy findings in the escaper, app shell and upload widget - LEDEscape looks entities up in a Map instead of indexing an object. - showNotification is declared as a global for app-shell.js. - openImageSchedule checks the index is a non-negative integer and reads the image with Array.prototype.at. - The schedule editor calls escapeHtml directly and documents why its innerHTML template is safe: every value is escaped or constrained. The remaining rule hits are suppressed on that line with the reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): build the image schedule editor with DOM calls Codacy does not honour inline suppressions, and the editor's innerHTML template kept tripping its XSS rules even though every value was escaped. The editor is now built with a small element helper (createElement and setAttribute), so no value is ever parsed as HTML, and the file's own escapeHtml goes away. Also for Codacy: - LEDEscape.attr is its own function rather than a second name for html. - The tab loader records a failed load on the panel (data-load-failed) from a named handler, instead of a closure over a local flag. The fake DOM in test_file_upload_widget.js gains append/replaceChildren, its hostile-id check now asserts the id arrives as attribute data with no innerHTML anywhere in the editor, and test_html_escaping.js drops the file-upload.js escaper it no longer has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): schedule editor helpers as plain functions Codacy's lint flags arrow functions held in local constants and a forEach callback that returns a value. The editor's pieces are now named function declarations (displayStyle, scheduleModeOption, scheduleRangeTime, scheduleDayTime, scheduleDayRow) taking what they need as arguments, and the element helper loops with for...of. htmx is declared as a global in app-shell.js. Output is unchanged; test_file_upload_widget.js passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1388 lines
68 KiB
JavaScript
1388 lines
68 KiB
JavaScript
/* global debugLog, showNotification, htmx */
|
|
/*
|
|
* app-shell.js -- the page shell: the Alpine app(), tab loading, live
|
|
* streams, header stats, and the handlers the plugin config tab calls.
|
|
*
|
|
* Deferred, and placed before alpinejs.min.js so window.app is the full
|
|
* implementation by the time Alpine starts, and the alpine:init listener
|
|
* (Alpine stores) is registered first.
|
|
*
|
|
* Load order (templates/v3/base.html):
|
|
* <head>, blocking: debugLog and theme inline scripts; the htmx loader
|
|
* (injects htmx.min.js with a dynamic <script>);
|
|
* js/htmx-config.js; the loadPartialDirect fallback;
|
|
* js/app-early.js
|
|
* <head>, defer: js/app-shell.js, then js/alpinejs.min.js (Alpine
|
|
* starts as soon as it runs, so app-shell.js's app()
|
|
* is the one Alpine uses)
|
|
* end of <body>, defer, in this order: app.js, js/tooltips.js,
|
|
* js/settings-search.js, js/utils/dialog.js,
|
|
* js/utils/error_handler.js, js/plugins/api_client.js,
|
|
* state_manager.js, install_manager.js, list_filter.js,
|
|
* the widget bundle (web_interface/widget_bundle.py),
|
|
* plugins_manager.js
|
|
* Tab partials arrive later through htmx; their inline scripts run on
|
|
* htmx:afterSwap (js/htmx-config.js).
|
|
*
|
|
* Globals:
|
|
* window.app the root component: activeTab, plugin tab
|
|
* row, loadTabContent (htmx, with a
|
|
* loadPartialDirect fallback)
|
|
* window.LEDVisibility run a partial's timers only while its tab is
|
|
* active and the page is visible
|
|
* window.LEDStreams the one owner of the stats and display SSE
|
|
* streams (window.statsSource / displaySource)
|
|
* window.showNotification a queueing stand-in until the notification
|
|
* widget loads
|
|
* Alpine stores 'onDemand' and 'plugins'
|
|
* Template handlers: fixInvalidNumberInputs, validatePluginConfigForm,
|
|
* handleConfigSave, handleToggleResponse, handlePluginUpdate,
|
|
* refreshPluginConfig, runPluginOnDemand, stopOnDemand,
|
|
* dismissPowerWarningBanner, takeScreenshot
|
|
* Plain functions other scripts and the Overview partial call:
|
|
* updateSystemStats, updateDisplayPreview, renderLedDots, drawGrid,
|
|
* updatePlugin (the plugin card's Update button)
|
|
*/
|
|
// htmx:responseError on a tab panel: marks the load as failed so the tab
|
|
// is not stamped data-loaded and reloads on the next visit (see loadTabContent).
|
|
function markPanelLoadFailed(event) {
|
|
event.currentTarget.setAttribute('data-load-failed', 'true');
|
|
}
|
|
|
|
function _setConnectionStatus(connected, reconnecting, paused) {
|
|
const el = document.getElementById('connection-status');
|
|
if (!el) return;
|
|
if (paused) {
|
|
// Intentionally closed while the page is hidden — not an error.
|
|
el.innerHTML = `
|
|
<div class="w-2 h-2 bg-gray-200 border border-gray-300 rounded-full"></div>
|
|
<span class="text-gray-600" title="Live updates pause while this page is in the background">Paused</span>
|
|
`;
|
|
} else if (connected) {
|
|
el.innerHTML = `
|
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<span class="text-gray-600">Connected</span>
|
|
`;
|
|
} else if (reconnecting) {
|
|
el.innerHTML = `
|
|
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
|
<span class="text-gray-600">Reconnecting…</span>
|
|
`;
|
|
} else {
|
|
el.innerHTML = `
|
|
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
<span class="text-gray-600" title="Connection lost — try refreshing the page">Disconnected</span>
|
|
`;
|
|
}
|
|
}
|
|
|
|
var _statsErrorCount = 0;
|
|
|
|
// LEDStreams attaches these to every EventSource it opens.
|
|
window._statsOpenHandler = function() {
|
|
_statsErrorCount = 0;
|
|
_setConnectionStatus(true, false);
|
|
};
|
|
window._statsErrorHandler = function() {
|
|
_statsErrorCount++;
|
|
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
|
|
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
|
|
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
|
|
};
|
|
window._displayErrorHandler = function() {
|
|
// Display stream errors don't change the status badge but log to console
|
|
// so failures aren't completely silent.
|
|
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
|
|
};
|
|
|
|
// ===== Tab/page visibility — shared by partials =====
|
|
// LEDVisibility.onActive(tab, start, stop[, key]) runs start() when
|
|
// `tab` is the active tab AND the page is visible, and stop() when
|
|
// either stops being true. start() should refresh immediately. The
|
|
// registration is keyed (default: tab name), so a partial re-injected
|
|
// by HTMX replaces its previous registration — the old stop() runs
|
|
// first — instead of stacking intervals. Returns an unregister fn.
|
|
// Tab changes are also broadcast as `ledmatrix:tab-changed` on document.
|
|
window.LEDVisibility = (function() {
|
|
const regs = new Map();
|
|
let lastTab = null;
|
|
|
|
function activeTab() {
|
|
const app = window.getApp();
|
|
return (app && app.activeTab) || lastTab;
|
|
}
|
|
function isActive(tab) {
|
|
return !document.hidden && activeTab() === tab;
|
|
}
|
|
function evaluate(reg) {
|
|
const want = isActive(reg.tab);
|
|
if (want === reg.running) return;
|
|
reg.running = want;
|
|
try {
|
|
(want ? reg.start : reg.stop)();
|
|
} catch (e) {
|
|
console.error('[LEDVisibility] ' + reg.key + (want ? ' start' : ' stop') + ' failed:', e);
|
|
}
|
|
}
|
|
function onActive(tab, start, stop, key) {
|
|
key = key || tab;
|
|
const prev = regs.get(key);
|
|
if (prev && prev.running) {
|
|
prev.running = false;
|
|
try { prev.stop(); } catch (e) { console.error('[LEDVisibility] ' + key + ' stop failed:', e); }
|
|
}
|
|
const reg = { tab: tab, start: start, stop: stop, key: key, running: false };
|
|
regs.set(key, reg);
|
|
evaluate(reg);
|
|
return function() {
|
|
if (regs.get(key) !== reg) return;
|
|
regs.delete(key);
|
|
if (reg.running) { reg.running = false; stop(); }
|
|
};
|
|
}
|
|
function refresh() {
|
|
regs.forEach(evaluate);
|
|
if (window.LEDStreams) window.LEDStreams.refresh();
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', refresh);
|
|
document.addEventListener('ledmatrix:tab-changed', function(e) {
|
|
lastTab = (e.detail && e.detail.tab) || lastTab;
|
|
refresh();
|
|
});
|
|
|
|
return { activeTab: activeTab, isActive: isActive, onActive: onActive, refresh: refresh };
|
|
})();
|
|
|
|
// ===== Live SSE streams — the single owner =====
|
|
// /stream/stats feeds the header stats and connection badge on every
|
|
// tab, so it stays open while the page is visible. /stream/display
|
|
// pushes base64 PNG frames and is only opened while a preview is on
|
|
// screen: the Overview live preview, or the floating preview when open.
|
|
// Both close while the page is hidden. The server's broadcaster ends its
|
|
// generator thread once the last subscriber leaves, so closing here
|
|
// saves Pi CPU, not only bandwidth.
|
|
//
|
|
// window.statsSource / window.displaySource remain public. An
|
|
// EventSource can't be reopened, so each reopen creates a new one;
|
|
// listeners other code attached with addEventListener (e.g. the Tools
|
|
// power panel) are recorded and re-attached to the replacement.
|
|
window.LEDStreams = (function() {
|
|
const CONFIG = {
|
|
stats: {
|
|
url: '/api/v3/stream/stats',
|
|
prop: 'statsSource',
|
|
onmessage: function(data) { updateSystemStats(data); },
|
|
wire: function(src) {
|
|
src.onopen = window._statsOpenHandler;
|
|
src.onerror = window._statsErrorHandler;
|
|
}
|
|
},
|
|
display: {
|
|
url: '/api/v3/stream/display',
|
|
prop: 'displaySource',
|
|
onmessage: function(data) { updateDisplayPreview(data); },
|
|
wire: function(src) { src.onerror = window._displayErrorHandler; }
|
|
}
|
|
};
|
|
const open = { stats: null, display: null };
|
|
const external = { stats: [], display: [] };
|
|
|
|
function openStream(name) {
|
|
if (open[name]) return;
|
|
const cfg = CONFIG[name];
|
|
const src = new EventSource(cfg.url);
|
|
const add = src.addEventListener.bind(src);
|
|
const remove = src.removeEventListener.bind(src);
|
|
src.addEventListener = function(type, listener, options) {
|
|
external[name].push([type, listener, options]);
|
|
add(type, listener, options);
|
|
};
|
|
src.removeEventListener = function(type, listener, options) {
|
|
external[name] = external[name].filter(function(l) {
|
|
return !(l[0] === type && l[1] === listener);
|
|
});
|
|
remove(type, listener, options);
|
|
};
|
|
external[name].forEach(function(l) { add(l[0], l[1], l[2]); });
|
|
src.onmessage = function(event) {
|
|
let data;
|
|
try { data = JSON.parse(event.data); } catch { return; }
|
|
cfg.onmessage(data);
|
|
};
|
|
cfg.wire(src);
|
|
open[name] = src;
|
|
window[cfg.prop] = src;
|
|
}
|
|
|
|
// The closed instance stays on window so late `addEventListener`
|
|
// calls are still recorded and replayed on the next open.
|
|
function closeStream(name) {
|
|
if (!open[name]) return;
|
|
open[name].close();
|
|
open[name] = null;
|
|
}
|
|
|
|
function previewOnScreen() {
|
|
if (window.LEDVisibility.activeTab() === 'overview') return true;
|
|
const panel = document.getElementById('floating-preview');
|
|
return !!(panel && panel.style.display === 'block');
|
|
}
|
|
|
|
function refresh() {
|
|
if (document.hidden) {
|
|
// Also covers a page first loaded in a background tab.
|
|
closeStream('stats');
|
|
closeStream('display');
|
|
_setConnectionStatus(false, false, true);
|
|
return;
|
|
}
|
|
if (!open.stats) {
|
|
_statsErrorCount = 0;
|
|
openStream('stats');
|
|
}
|
|
if (previewOnScreen()) openStream('display');
|
|
else closeStream('display');
|
|
}
|
|
|
|
function reconnect() {
|
|
closeStream('stats');
|
|
closeStream('display');
|
|
refresh();
|
|
}
|
|
|
|
return { refresh: refresh, reconnect: reconnect, isOpen: function(name) { return !!open[name]; } };
|
|
})();
|
|
|
|
// Header stats start immediately; the display stream opens once the
|
|
// active tab is known (Alpine init dispatches ledmatrix:tab-changed).
|
|
window.LEDStreams.refresh();
|
|
document.addEventListener('DOMContentLoaded', function() { window.LEDStreams.refresh(); });
|
|
|
|
// Reset any time the currently-active warning clears, so a future
|
|
// (new) occurrence shows the banner again even if this one was dismissed.
|
|
window._powerWarningDismissed = false;
|
|
|
|
window.dismissPowerWarningBanner = function() {
|
|
const banner = document.getElementById('power-warning-banner');
|
|
if (banner) banner.style.display = 'none';
|
|
window._powerWarningDismissed = true;
|
|
};
|
|
|
|
// Labels for whichever flags from _get_power_status() are set (pass
|
|
// suffix='_occurred' for the "happened earlier" variant), used to
|
|
// build accurate banner/tooltip text instead of hardcoding
|
|
// "under-voltage" for what may actually be throttling/freq-capping/
|
|
// thermal limiting.
|
|
function _activePowerConditionLabels(power, suffix) {
|
|
suffix = suffix || '_now';
|
|
const labels = [];
|
|
if (power['under_voltage' + suffix]) labels.push('under-voltage');
|
|
if (power['throttled' + suffix]) labels.push('throttling');
|
|
if (power['freq_capped' + suffix]) labels.push('CPU frequency capped');
|
|
if (power['soft_temp_limit' + suffix]) labels.push('soft thermal limit');
|
|
return labels;
|
|
}
|
|
|
|
function updatePowerStatus(power) {
|
|
const statEl = document.getElementById('power-stat');
|
|
const banner = document.getElementById('power-warning-banner');
|
|
const bannerText = document.getElementById('power-warning-banner-text');
|
|
|
|
if (!power) {
|
|
if (statEl) statEl.classList.add('hidden');
|
|
if (banner) {
|
|
banner.style.display = 'none';
|
|
// Let a future occurrence show the banner again rather
|
|
// than leaving stale text/visibility from before this
|
|
// (likely transient) missing-data tick.
|
|
window._powerWarningDismissed = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const activeNow = power.under_voltage_now || power.throttled_now ||
|
|
power.freq_capped_now || power.soft_temp_limit_now;
|
|
const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred ||
|
|
power.freq_capped_occurred || power.soft_temp_limit_occurred;
|
|
|
|
if (statEl) {
|
|
statEl.classList.remove('text-red-600', 'text-yellow-600');
|
|
if (activeNow) {
|
|
statEl.classList.remove('hidden');
|
|
statEl.classList.add('flex', 'text-red-600');
|
|
statEl.title = _activePowerConditionLabels(power).join('/') +
|
|
' detected right now — check your power supply and cooling';
|
|
} else if (occurredEarlier) {
|
|
statEl.classList.remove('hidden');
|
|
statEl.classList.add('flex', 'text-yellow-600');
|
|
const occurredLabels = _activePowerConditionLabels(power, '_occurred');
|
|
statEl.title = (occurredLabels.length ? occurredLabels.join('/') : 'An issue') +
|
|
' was detected earlier (currently OK)';
|
|
} else {
|
|
statEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
if (banner) {
|
|
if (activeNow) {
|
|
if (bannerText) {
|
|
const labels = _activePowerConditionLabels(power);
|
|
bannerText.textContent = (labels.length ? labels.join('/') : 'A power/thermal issue') +
|
|
' detected right now — the display may flicker or degrade. Check your power supply and cooling.';
|
|
}
|
|
if (!window._powerWarningDismissed) {
|
|
banner.style.display = '';
|
|
}
|
|
} else {
|
|
banner.style.display = 'none';
|
|
// Let a future occurrence show the banner again.
|
|
window._powerWarningDismissed = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// A metric the server could not read arrives as null (off a Pi there is
|
|
// no CPU temperature); show the same '--' placeholder the page starts
|
|
// with rather than "null°C".
|
|
function statText(value, unit) {
|
|
return (value == null ? '--' : value) + unit;
|
|
}
|
|
|
|
function updateSystemStats(data) {
|
|
// Header: the value is the last <span> inside each stat.
|
|
const header = [['cpu-stat', data.cpu_percent, '%'],
|
|
['memory-stat', data.memory_used_percent, '%'],
|
|
['temp-stat', data.cpu_temp, '°C']];
|
|
header.forEach(([id, value, unit]) => {
|
|
const spans = document.getElementById(id)?.querySelectorAll('span');
|
|
if (spans && spans.length > 0) spans[spans.length - 1].textContent = statText(value, unit);
|
|
});
|
|
|
|
updatePowerStatus(data.power);
|
|
|
|
// Overview tab (only present while it is loaded)
|
|
const overview = [['cpu-usage', data.cpu_percent, '%'],
|
|
['memory-usage', data.memory_used_percent, '%'],
|
|
['cpu-temp', data.cpu_temp, '°C']];
|
|
overview.forEach(([id, value, unit]) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.textContent = statText(value, unit);
|
|
});
|
|
|
|
const displayStatusEl = document.getElementById('display-status');
|
|
if (displayStatusEl) {
|
|
displayStatusEl.textContent = data.service_active ? 'Active' : 'Inactive';
|
|
displayStatusEl.className = data.service_active ?
|
|
'text-lg font-medium text-green-600' :
|
|
'text-lg font-medium text-red-600';
|
|
}
|
|
}
|
|
|
|
window.__onDemandStore = window.__onDemandStore || {
|
|
loading: true,
|
|
state: {},
|
|
service: {},
|
|
error: null,
|
|
lastUpdated: null
|
|
};
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
// On-Demand state store
|
|
if (window.Alpine && !window.Alpine.store('onDemand')) {
|
|
window.Alpine.store('onDemand', {
|
|
loading: window.__onDemandStore.loading,
|
|
state: window.__onDemandStore.state,
|
|
service: window.__onDemandStore.service,
|
|
error: window.__onDemandStore.error,
|
|
lastUpdated: window.__onDemandStore.lastUpdated
|
|
});
|
|
}
|
|
if (window.Alpine) {
|
|
window.__onDemandStore = window.Alpine.store('onDemand');
|
|
}
|
|
|
|
// Plugin state store - centralized state management for plugins
|
|
// Used primarily by HTMX-loaded plugin config partials
|
|
if (window.Alpine && !window.Alpine.store('plugins')) {
|
|
window.Alpine.store('plugins', {
|
|
// Track which plugin configs have been loaded
|
|
loadedConfigs: {},
|
|
|
|
// Mark a plugin config as loaded
|
|
markLoaded(pluginId) {
|
|
this.loadedConfigs[pluginId] = true;
|
|
},
|
|
|
|
// Check if a plugin config is loaded
|
|
isLoaded(pluginId) {
|
|
return !!this.loadedConfigs[pluginId];
|
|
},
|
|
|
|
// Refresh a plugin config tab via HTMX
|
|
refreshConfig(pluginId) {
|
|
const container = document.querySelector(`#plugin-config-${pluginId}`);
|
|
if (container && window.htmx) {
|
|
htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
|
|
target: container,
|
|
swap: 'innerHTML'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
// The full app(). Alpine calls it for <body x-data="app()">.
|
|
function app() {
|
|
const fullImplementation = {
|
|
activeTab: (function() {
|
|
// Auto-open WiFi tab when in AP mode (192.168.4.x)
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
return isAPMode ? 'wifi' : 'overview';
|
|
})(),
|
|
mobileNavOpen: false,
|
|
installedPlugins: [],
|
|
|
|
init() {
|
|
if (this._initialized) {
|
|
return;
|
|
}
|
|
this._initialized = true;
|
|
|
|
// Load plugins on page load so tabs are available on any page, regardless of active tab
|
|
// First check if plugins are already in window.installedPlugins (from plugins_manager.js)
|
|
if (typeof window.installedPlugins !== 'undefined' && Array.isArray(window.installedPlugins) && window.installedPlugins.length > 0) {
|
|
this.installedPlugins = window.installedPlugins;
|
|
debugLog('Initialized installedPlugins from global:', this.installedPlugins.length);
|
|
// Ensure tabs are updated immediately
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
} else if (!this.installedPlugins || this.installedPlugins.length === 0) {
|
|
// Load plugins asynchronously, but ensure tabs update when done
|
|
this.loadInstalledPlugins().then(() => {
|
|
// Ensure tabs are updated after loading
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
}).catch(err => {
|
|
console.error('Error loading plugins in init:', err);
|
|
// Still try to update tabs in case some plugins are available
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
});
|
|
} else {
|
|
// Plugins already loaded, just update tabs
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
}
|
|
|
|
// Ensure content loads for the active tab
|
|
this.$watch('activeTab', (newTab, oldTab) => {
|
|
// Update plugin tab states when activeTab changes
|
|
if (typeof this.updatePluginTabStates === 'function') {
|
|
this.updatePluginTabStates();
|
|
}
|
|
// Screen readers announce the current tab (covers every
|
|
// path that changes tabs: clicks, search deep links,
|
|
// the getting-started checklist)
|
|
if (typeof window.updateNavAriaCurrent === 'function') {
|
|
window.updateNavAriaCurrent(newTab);
|
|
}
|
|
// Floating preview hides on Overview (full preview
|
|
// there), reappears per its saved state elsewhere
|
|
if (typeof window.updateFloatingPreviewVisibility === 'function') {
|
|
window.updateFloatingPreviewVisibility(newTab);
|
|
}
|
|
// Pause/resume per-tab timers and SSE (LEDVisibility)
|
|
document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', {
|
|
detail: { tab: newTab, previous: oldTab }
|
|
}));
|
|
// Trigger content load when tab changes
|
|
this.$nextTick(() => {
|
|
this.loadTabContent(newTab);
|
|
});
|
|
});
|
|
|
|
this.$nextTick(() => {
|
|
this.loadTabContent(this.activeTab);
|
|
if (typeof window.updateNavAriaCurrent === 'function') {
|
|
window.updateNavAriaCurrent(this.activeTab);
|
|
}
|
|
document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', {
|
|
detail: { tab: this.activeTab, previous: null }
|
|
}));
|
|
});
|
|
|
|
// The installed list is published by renderInstalledPlugins
|
|
// (plugins_manager.js) with one pluginsUpdated event.
|
|
document.addEventListener('pluginsUpdated', (event) => {
|
|
debugLog('Received pluginsUpdated event:', event.detail.plugins.length, 'plugins');
|
|
this.installedPlugins = event.detail.plugins;
|
|
this.updatePluginTabs();
|
|
});
|
|
|
|
// Also listen for direct window.installedPlugins changes
|
|
// Store the actual value in a private property to avoid infinite loops
|
|
let _installedPluginsValue = this.installedPlugins || [];
|
|
|
|
// Only define the property if it doesn't already exist or if it's configurable
|
|
const existingDescriptor = Object.getOwnPropertyDescriptor(window, 'installedPlugins');
|
|
if (!existingDescriptor || existingDescriptor.configurable) {
|
|
// Delete existing property if it exists and is configurable
|
|
if (existingDescriptor) {
|
|
delete window.installedPlugins;
|
|
}
|
|
|
|
Object.defineProperty(window, 'installedPlugins', {
|
|
set: (value) => {
|
|
const newPlugins = value || [];
|
|
const oldIds = (_installedPluginsValue || []).map(p => p.id).sort().join(',');
|
|
const newIds = newPlugins.map(p => p.id).sort().join(',');
|
|
|
|
// Always take the new list — same-ID updates
|
|
// still carry changed metadata/enabled state.
|
|
_installedPluginsValue = newPlugins;
|
|
this.installedPlugins = newPlugins;
|
|
// Only rebuild the tab row when the ID set
|
|
// actually changed.
|
|
if (oldIds !== newIds) {
|
|
debugLog('window.installedPlugins changed:', newPlugins.length, 'plugins');
|
|
this.updatePluginTabs();
|
|
}
|
|
},
|
|
get: () => _installedPluginsValue,
|
|
configurable: true // Allow redefinition if needed
|
|
});
|
|
} else {
|
|
// Property already exists and is not configurable, just update the value
|
|
if (typeof window.installedPlugins !== 'undefined') {
|
|
_installedPluginsValue = window.installedPlugins;
|
|
}
|
|
}
|
|
|
|
},
|
|
|
|
loadTabContent(tab) {
|
|
const contentEl = document.getElementById(tab + '-content');
|
|
// data-loaded: already fetched. data-loading: a fetch is queued or in
|
|
// flight. Both guard against re-entry so a panel loads exactly once, even
|
|
// if the tab is reopened before an in-progress (or polling) load settles.
|
|
if (!contentEl || contentEl.hasAttribute('data-loaded') || contentEl.hasAttribute('data-loading')) return;
|
|
const url = contentEl.getAttribute('hx-get');
|
|
if (!url) return;
|
|
|
|
contentEl.setAttribute('data-loading', 'true');
|
|
|
|
// htmx.ajax issues the request and swaps the response into the panel
|
|
// directly, so it works even before htmx has wired up the element's
|
|
// hx-trigger listeners. The panel is the request's source as well as
|
|
// its target, so htmx fires its events on the panel and the panel's
|
|
// hx-on::response-error handler runs. htmx resolves the promise even on
|
|
// an error status, so data-loaded is stamped only when no
|
|
// htmx:responseError fired; a failed panel reloads on the next visit.
|
|
// The activeTab check drops loads for a tab the user navigated away
|
|
// from while htmx was still loading (avoids fetching hidden panels).
|
|
const swap = contentEl.getAttribute('hx-swap') || 'innerHTML';
|
|
const load = () => {
|
|
if (this.activeTab !== tab || contentEl.hasAttribute('data-loaded')) {
|
|
contentEl.removeAttribute('data-loading');
|
|
return;
|
|
}
|
|
contentEl.removeAttribute('data-load-failed');
|
|
contentEl.addEventListener('htmx:responseError', markPanelLoadFailed, { once: true });
|
|
return htmx.ajax('GET', url, { source: contentEl, target: contentEl, swap: swap })
|
|
.then(() => {
|
|
if (!contentEl.hasAttribute('data-load-failed')) contentEl.setAttribute('data-loaded', 'true');
|
|
})
|
|
.catch(() => {}) // network failure: leave unstamped so it can retry
|
|
.finally(() => {
|
|
contentEl.removeEventListener('htmx:responseError', markPanelLoadFailed);
|
|
contentEl.removeAttribute('data-loading');
|
|
});
|
|
};
|
|
|
|
if (typeof htmx !== 'undefined') {
|
|
load();
|
|
return;
|
|
}
|
|
|
|
// base.html injects htmx with a dynamic <script>, so it can arrive
|
|
// after Alpine starts. Poll for it; if it never arrives, fetch directly.
|
|
let tries = 0;
|
|
const timer = setInterval(() => {
|
|
if (typeof htmx !== 'undefined') {
|
|
clearInterval(timer);
|
|
load();
|
|
} else if (++tries > 100) { // ~10s
|
|
clearInterval(timer);
|
|
contentEl.removeAttribute('data-loading');
|
|
window.loadPartialDirect(contentEl.id, url);
|
|
}
|
|
}, 100);
|
|
},
|
|
|
|
async loadInstalledPlugins() {
|
|
// If pluginManager exists (plugins.html is loaded), delegate to it
|
|
if (window.pluginManager) {
|
|
debugLog('[FULL] Delegating plugin loading to pluginManager...');
|
|
await window.pluginManager.loadInstalledPlugins();
|
|
// pluginManager should set window.installedPlugins, so update our component
|
|
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
|
this.installedPlugins = window.installedPlugins;
|
|
debugLog('[FULL] Updated component plugins from window.installedPlugins:', this.installedPlugins.length);
|
|
}
|
|
this.updatePluginTabs();
|
|
return;
|
|
}
|
|
|
|
// Otherwise, load plugins directly (fallback for when plugins.html isn't loaded)
|
|
try {
|
|
debugLog('[FULL] Loading installed plugins directly...');
|
|
const data = await getInstalledPluginsSafe();
|
|
|
|
if (data.status === 'success') {
|
|
this.installedPlugins = data.data.plugins || [];
|
|
// Also update window.installedPlugins for consistency
|
|
window.installedPlugins = this.installedPlugins;
|
|
debugLog(`[FULL] Loaded ${this.installedPlugins.length} plugins:`, this.installedPlugins.map(p => p.id));
|
|
|
|
// Debug: Log enabled status for each plugin
|
|
this.installedPlugins.forEach(plugin => {
|
|
debugLog(`[DEBUG Alpine] Plugin ${plugin.id}: enabled=${plugin.enabled} (type: ${typeof plugin.enabled})`);
|
|
});
|
|
|
|
this.updatePluginTabs();
|
|
} else {
|
|
console.error('[FULL] Failed to load plugins:', data.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('[FULL] Error loading installed plugins:', error);
|
|
}
|
|
},
|
|
|
|
// Rebuilds the plugin tab row now. app-early.js recognises the
|
|
// full implementation by the _doUpdatePluginTabs name in this
|
|
// method's source, so keep the call spelled out.
|
|
updatePluginTabs(retryCount = 0) {
|
|
this._doUpdatePluginTabs(retryCount);
|
|
},
|
|
|
|
_doUpdatePluginTabs(retryCount = 0) {
|
|
const maxRetries = 5;
|
|
|
|
// Use component's installedPlugins first (most up-to-date), then global, then empty array
|
|
const pluginsToShow = (this.installedPlugins && this.installedPlugins.length > 0)
|
|
? this.installedPlugins
|
|
: (window.installedPlugins || []);
|
|
|
|
debugLog('[FULL] _doUpdatePluginTabs called with:', pluginsToShow.length, 'plugins (attempt', retryCount + 1, ')');
|
|
debugLog('[FULL] Plugin sources:', {
|
|
componentPlugins: this.installedPlugins?.length || 0,
|
|
windowPlugins: window.installedPlugins?.length || 0,
|
|
using: pluginsToShow.length > 0 ? (this.installedPlugins?.length > 0 ? 'component' : 'window') : 'none'
|
|
});
|
|
|
|
// Check if plugin list actually changed by comparing IDs
|
|
const currentPluginIds = pluginsToShow.map(p => p.id).sort().join(',');
|
|
const lastRenderedIds = (this._lastRenderedPluginIds || '');
|
|
|
|
// Only skip if we have plugins and they match (don't skip if both are empty)
|
|
if (currentPluginIds === lastRenderedIds && retryCount === 0 && currentPluginIds.length > 0) {
|
|
// Plugin list hasn't changed, skip update
|
|
debugLog('[FULL] Plugin list unchanged, skipping update');
|
|
return;
|
|
}
|
|
|
|
// If we have no plugins and haven't rendered anything yet, still try to render (might be first load)
|
|
if (pluginsToShow.length === 0 && retryCount === 0) {
|
|
debugLog('[FULL] No plugins to show, but will retry in case they load...');
|
|
if (retryCount < maxRetries) {
|
|
setTimeout(() => {
|
|
this._doUpdatePluginTabs(retryCount + 1);
|
|
}, 500);
|
|
}
|
|
return;
|
|
}
|
|
|
|
this._lastRenderedPluginIds = currentPluginIds;
|
|
|
|
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
|
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
|
|
|
debugLog('[FULL] Plugin tabs elements:', {
|
|
pluginTabsRow: !!pluginTabsRow,
|
|
pluginTabsNav: !!pluginTabsNav,
|
|
bodyExists: !!document.body,
|
|
installedPlugins: pluginsToShow.length,
|
|
pluginIds: pluginsToShow.map(p => p.id)
|
|
});
|
|
|
|
if (!pluginTabsRow || !pluginTabsNav) {
|
|
if (retryCount < maxRetries) {
|
|
console.warn('[FULL] Plugin tabs container not found, retrying in 500ms... (attempt', retryCount + 1, 'of', maxRetries, ')');
|
|
setTimeout(() => {
|
|
this._doUpdatePluginTabs(retryCount + 1);
|
|
}, 500);
|
|
} else {
|
|
console.error('[FULL] Plugin tabs container not found after maximum retries. Elements:', {
|
|
pluginTabsRow: document.getElementById('plugin-tabs-row'),
|
|
pluginTabsNav: document.getElementById('plugin-tabs-row')?.querySelector('nav'),
|
|
allNavs: document.querySelectorAll('nav').length
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
debugLog(`[FULL] Updating plugin tabs for ${pluginsToShow.length} plugins`);
|
|
|
|
// The row also holds the Plugin Manager tab, so it is always shown.
|
|
pluginTabsRow.style.display = 'block';
|
|
|
|
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
debugLog(`[FULL] Removing ${existingTabs.length} existing plugin tabs`);
|
|
existingTabs.forEach(tab => tab.remove());
|
|
|
|
debugLog('[FULL] Adding tabs for plugins:', pluginsToShow.map(p => p.id));
|
|
pluginsToShow.forEach(plugin => {
|
|
const tabButton = document.createElement('button');
|
|
tabButton.type = 'button';
|
|
tabButton.setAttribute('data-plugin-id', plugin.id);
|
|
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
|
|
tabButton.onclick = () => {
|
|
this.activeTab = plugin.id;
|
|
if (typeof this.updatePluginTabStates === 'function') {
|
|
this.updatePluginTabStates();
|
|
}
|
|
};
|
|
// Build the <i class="..."> + label as DOM nodes so a
|
|
// hostile plugin.icon (e.g. containing a quote) can't
|
|
// break out of the attribute. escapeHtml now escapes
|
|
// quotes too, but setting the property directly cannot
|
|
// be got wrong at all, so it stays.
|
|
const iconEl = document.createElement('i');
|
|
iconEl.className = plugin.icon || 'fas fa-puzzle-piece';
|
|
const labelNode = document.createTextNode(plugin.name || plugin.id);
|
|
tabButton.replaceChildren(iconEl, labelNode);
|
|
|
|
pluginTabsNav.appendChild(tabButton);
|
|
debugLog('[FULL] Added tab for plugin:', plugin.id);
|
|
});
|
|
|
|
debugLog('[FULL] Plugin tabs update completed. Total tabs:', pluginTabsNav.querySelectorAll('.plugin-tab').length);
|
|
},
|
|
|
|
updatePluginTabStates() {
|
|
const pluginTabsNav = document.getElementById('plugin-tabs-row')?.querySelector('nav');
|
|
if (!pluginTabsNav) return;
|
|
|
|
const pluginTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
pluginTabs.forEach(tab => {
|
|
const pluginId = tab.getAttribute('data-plugin-id');
|
|
if (pluginId && this.activeTab === pluginId) {
|
|
tab.classList.add('nav-tab-active');
|
|
} else {
|
|
tab.classList.remove('nav-tab-active');
|
|
}
|
|
});
|
|
},
|
|
|
|
showNotification(message, type = 'info') {
|
|
window.showNotification(message, type);
|
|
}
|
|
};
|
|
|
|
window.app = function() {
|
|
return fullImplementation;
|
|
};
|
|
|
|
// If Alpine started on the app-early.js stub, copy the full
|
|
// implementation into that live component (next frame, once
|
|
// Alpine has finished initialising it).
|
|
if (window.Alpine) {
|
|
requestAnimationFrame(() => {
|
|
if (window._appEnhanced) return;
|
|
window._appEnhanced = true;
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
const defaultTab = isAPMode ? 'wifi' : 'overview';
|
|
const existingComponent = window.getApp();
|
|
if (existingComponent) {
|
|
// Preserve runtime state that should not be reset
|
|
const preservedPlugins = existingComponent.installedPlugins;
|
|
const preservedTab = existingComponent.activeTab;
|
|
// Replace all properties and methods from full implementation
|
|
Object.keys(fullImplementation).forEach(key => {
|
|
existingComponent[key] = fullImplementation[key];
|
|
});
|
|
// Restore runtime state if non-default
|
|
if (preservedPlugins && preservedPlugins.length > 0) {
|
|
existingComponent.installedPlugins = preservedPlugins;
|
|
}
|
|
if (preservedTab && preservedTab !== defaultTab) {
|
|
existingComponent.activeTab = preservedTab;
|
|
}
|
|
// Call init to load plugins and set up watchers (only if not already initialized)
|
|
if (typeof existingComponent.init === 'function' && !existingComponent._initialized) {
|
|
existingComponent.init();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return fullImplementation;
|
|
}
|
|
|
|
window.app = app;
|
|
|
|
|
|
// ===== Display preview (Overview tab and the floating preview) =====
|
|
|
|
function updateDisplayPreview(data) {
|
|
const preview = document.getElementById('displayPreview');
|
|
const stage = document.getElementById('previewStage');
|
|
const img = document.getElementById('displayImage');
|
|
const canvas = document.getElementById('gridOverlay');
|
|
const ledCanvas = document.getElementById('ledCanvas');
|
|
const placeholder = document.getElementById('displayPlaceholder');
|
|
|
|
// Always cache the latest frame so the floating preview can show
|
|
// something the moment it's opened — SSE only sends frames when
|
|
// the display CHANGES, so a late subscriber would otherwise stare
|
|
// at an empty panel until the next change.
|
|
if (data.image) {
|
|
window._lastPreviewFrame = data.image;
|
|
}
|
|
// Feed the floating mini preview (lives in base.html, present on
|
|
// every tab) before the overview-only guard below.
|
|
const floatImg = document.getElementById('floating-preview-img');
|
|
const floatPanel = document.getElementById('floating-preview');
|
|
if (floatImg && floatPanel && floatPanel.style.display !== 'none' && data.image) {
|
|
floatImg.src = `data:image/png;base64,${data.image}`;
|
|
}
|
|
|
|
if (!stage || !img || !placeholder) return; // Not on overview page
|
|
|
|
if (data.image) {
|
|
// Show stage
|
|
placeholder.style.display = 'none';
|
|
stage.style.display = 'inline-block';
|
|
|
|
// Current scale from slider
|
|
const scale = parseInt(document.getElementById('scaleRange')?.value || '8');
|
|
|
|
// Update image and meta label. Size everything from the PNG's
|
|
// OWN dimensions (naturalWidth/Height) once it has loaded —
|
|
// not from the server-reported config dimensions. If the
|
|
// config disagrees with what the display service actually
|
|
// renders (stale config, service not yet restarted), sizing
|
|
// from config stretches the image at a fractional ratio and
|
|
// the preview looks blurry until the scale slider forces a
|
|
// re-render. The slider path always used natural dimensions;
|
|
// now both paths do.
|
|
img.style.imageRendering = 'pixelated';
|
|
img.onload = () => {
|
|
const nw = img.naturalWidth || data.width || 128;
|
|
const nh = img.naturalHeight || data.height || 64;
|
|
const width = nw * scale;
|
|
const height = nh * scale;
|
|
img.style.width = width + 'px';
|
|
img.style.height = height + 'px';
|
|
ledCanvas.width = width;
|
|
ledCanvas.height = height;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const meta = document.getElementById('previewMeta');
|
|
if (meta) {
|
|
meta.textContent = `${nw} x ${nh} @ ${scale}x`;
|
|
}
|
|
drawGrid(canvas, nw, nh, scale);
|
|
renderLedDots();
|
|
};
|
|
img.src = `data:image/png;base64,${data.image}`;
|
|
} else {
|
|
stage.style.display = 'none';
|
|
placeholder.style.display = 'block';
|
|
placeholder.innerHTML = `<div class="text-center text-gray-400 py-8">
|
|
<i class="fas fa-exclamation-triangle text-4xl mb-3"></i>
|
|
<p>No display data available</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function renderLedDots() {
|
|
const ledCanvas = document.getElementById('ledCanvas');
|
|
const img = document.getElementById('displayImage');
|
|
const toggle = document.getElementById('toggleLedDots');
|
|
|
|
if (!ledCanvas || !img || !toggle) {
|
|
return;
|
|
}
|
|
|
|
const show = toggle.checked;
|
|
|
|
if (!show) {
|
|
// LED mode OFF: Show image, hide canvas
|
|
img.style.visibility = 'visible';
|
|
ledCanvas.style.display = 'none';
|
|
const ctx = ledCanvas.getContext('2d');
|
|
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
return;
|
|
}
|
|
|
|
// LED mode ON: Hide image (but keep layout space), show only dots on canvas
|
|
img.style.visibility = 'hidden';
|
|
ledCanvas.style.display = 'block';
|
|
|
|
const scale = parseInt(document.getElementById('scaleRange')?.value || '8');
|
|
const fillPct = parseInt(document.getElementById('dotFillRange')?.value || '75');
|
|
const dotRadius = Math.max(1, Math.floor((scale * fillPct) / 200)); // radius in px
|
|
|
|
const ctx = ledCanvas.getContext('2d', { willReadFrequently: true });
|
|
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
|
|
// Create an offscreen canvas to sample pixel colors
|
|
const off = document.createElement('canvas');
|
|
const logicalWidth = Math.floor(ledCanvas.width / scale);
|
|
const logicalHeight = Math.floor(ledCanvas.height / scale);
|
|
off.width = logicalWidth;
|
|
off.height = logicalHeight;
|
|
const offCtx = off.getContext('2d', { willReadFrequently: true });
|
|
|
|
// Draw the current image scaled down to logical LEDs to sample colors
|
|
try {
|
|
offCtx.drawImage(img, 0, 0, logicalWidth, logicalHeight);
|
|
} catch (e) {
|
|
console.error('Failed to draw image to offscreen canvas:', e);
|
|
return;
|
|
}
|
|
|
|
// Fill canvas with black background (LED matrix bezel)
|
|
ctx.fillStyle = 'rgb(0, 0, 0)';
|
|
ctx.fillRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
|
|
// Read the whole frame once instead of one getImageData call per
|
|
// pixel (a 192x48 panel would otherwise issue ~9,200 calls per
|
|
// frame — noticeably heavy on phones).
|
|
let frame;
|
|
try {
|
|
frame = offCtx.getImageData(0, 0, logicalWidth, logicalHeight).data;
|
|
} catch (e) {
|
|
console.error('Failed to read offscreen canvas pixels:', e);
|
|
return;
|
|
}
|
|
|
|
// Draw circular dots for each LED pixel
|
|
let drawn = 0;
|
|
for (let y = 0; y < logicalHeight; y++) {
|
|
for (let x = 0; x < logicalWidth; x++) {
|
|
const i = (y * logicalWidth + x) * 4;
|
|
const r = frame[i], g = frame[i + 1], b = frame[i + 2], a = frame[i + 3];
|
|
|
|
// Skip fully transparent or black pixels to reduce overdraw
|
|
if (a === 0 || (r|g|b) === 0) continue;
|
|
|
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
|
const cx = Math.floor(x * scale + scale / 2);
|
|
const cy = Math.floor(y * scale + scale / 2);
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, dotRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
drawn++;
|
|
}
|
|
}
|
|
|
|
// If nothing was drawn (e.g., image not ready), hide overlay to show base image
|
|
if (drawn === 0) {
|
|
ledCanvas.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function drawGrid(canvas, pixelWidth, pixelHeight, scale) {
|
|
const toggle = document.getElementById('toggleGrid');
|
|
if (!toggle || !toggle.checked) {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
return;
|
|
}
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let x = 0; x <= pixelWidth; x++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x * scale, 0);
|
|
ctx.lineTo(x * scale, pixelHeight * scale);
|
|
ctx.stroke();
|
|
}
|
|
|
|
for (let y = 0; y <= pixelHeight; y++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y * scale);
|
|
ctx.lineTo(pixelWidth * scale, y * scale);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function takeScreenshot() {
|
|
const img = document.getElementById('displayImage');
|
|
if (img && img.src) {
|
|
const link = document.createElement('a');
|
|
link.download = `led_matrix_${new Date().getTime()}.png`;
|
|
link.href = img.src;
|
|
link.click();
|
|
}
|
|
}
|
|
|
|
// Clamps number inputs to their min/max before a form submits, so the
|
|
// browser does not block the submit with "An invalid form control is
|
|
// not focusable" for a field the user cannot see. Called from the
|
|
// Display and Durations forms' onsubmit.
|
|
window.fixInvalidNumberInputs = function(form) {
|
|
if (!form) return;
|
|
const allInputs = form.querySelectorAll('input[type="number"]');
|
|
allInputs.forEach(input => {
|
|
const min = parseFloat(input.getAttribute('min'));
|
|
const max = parseFloat(input.getAttribute('max'));
|
|
const value = parseFloat(input.value);
|
|
|
|
if (!isNaN(value)) {
|
|
if (!isNaN(min) && value < min) {
|
|
input.value = min;
|
|
} else if (!isNaN(max) && value > max) {
|
|
input.value = max;
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// Stand-in for window.showNotification until the notification widget
|
|
// (widgets/notification.js, in the widget bundle) loads: it queues
|
|
// messages, and the widget shows the queue and replaces this function.
|
|
// This script runs before every other script that notifies, so callers
|
|
// use showNotification without checking that it exists.
|
|
if (typeof window.showNotification !== 'function') {
|
|
window.showNotification = function(message, type = 'info') {
|
|
const registry = window.LEDMatrixWidgets;
|
|
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
|
|
if (widget && typeof widget.show === 'function') {
|
|
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
|
|
}
|
|
if (!Array.isArray(window.__pendingNotifications)) {
|
|
window.__pendingNotifications = [];
|
|
}
|
|
window.__pendingNotifications.push([message, type]);
|
|
debugLog(`[${String((type && type.type) || type).toUpperCase()}]`, message);
|
|
};
|
|
}
|
|
|
|
// Plugin ids whose config tab is reloading (see refreshPluginConfig).
|
|
window.pluginConfigRefreshInProgress = window.pluginConfigRefreshInProgress || new Set();
|
|
|
|
// The parsed JSON body of an htmx request's XMLHttpRequest, or null.
|
|
// (XMLHttpRequest has no responseJSON; that is a jQuery property.)
|
|
function xhrJson(xhr) {
|
|
try {
|
|
return xhr && xhr.responseText ? JSON.parse(xhr.responseText) : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks a plugin config form before htmx posts it. Called from the
|
|
* form's onsubmit in partials/plugin_config.html
|
|
* (`return validatePluginConfigForm(this, pluginId)`).
|
|
* On failure it expands the collapsed sections holding invalid
|
|
* fields, focuses the first one, and shows the errors.
|
|
* @returns {boolean} true to let the submit (and htmx) proceed,
|
|
* false to cancel it.
|
|
*/
|
|
window.validatePluginConfigForm = function(form, pluginId) {
|
|
// Check HTML5 validation
|
|
if (!form.checkValidity()) {
|
|
// Find all invalid fields
|
|
const invalidFields = Array.from(form.querySelectorAll(':invalid'));
|
|
const errors = [];
|
|
let firstInvalidField = null;
|
|
|
|
invalidFields.forEach((field, index) => {
|
|
// Build error message
|
|
let fieldName = field.name || field.id || 'field';
|
|
// Make field name more readable (remove plugin ID prefix, convert dots/underscores)
|
|
fieldName = fieldName.replace(new RegExp('^' + pluginId + '-'), '')
|
|
.replace(/\./g, ' → ')
|
|
.replace(/_/g, ' ')
|
|
.replace(/\b\w/g, l => l.toUpperCase()); // Capitalize words
|
|
|
|
let errorMsg = field.validationMessage || 'Invalid value';
|
|
|
|
// Get more specific error message based on validation state
|
|
if (field.validity.valueMissing) {
|
|
errorMsg = 'This field is required';
|
|
} else if (field.validity.rangeUnderflow) {
|
|
errorMsg = `Value must be at least ${field.min || 'the minimum'}`;
|
|
} else if (field.validity.rangeOverflow) {
|
|
errorMsg = `Value must be at most ${field.max || 'the maximum'}`;
|
|
} else if (field.validity.stepMismatch) {
|
|
errorMsg = `Value must be a multiple of ${field.step || 1}`;
|
|
} else if (field.validity.typeMismatch) {
|
|
errorMsg = 'Invalid format (e.g., text in number field)';
|
|
} else if (field.validity.patternMismatch) {
|
|
errorMsg = 'Value does not match required pattern';
|
|
} else if (field.validity.tooShort) {
|
|
errorMsg = `Value must be at least ${field.minLength} characters`;
|
|
} else if (field.validity.tooLong) {
|
|
errorMsg = `Value must be at most ${field.maxLength} characters`;
|
|
} else if (field.validity.badInput) {
|
|
errorMsg = 'Invalid input type';
|
|
}
|
|
|
|
errors.push(`${fieldName}: ${errorMsg}`);
|
|
|
|
// Track first invalid field for focusing
|
|
if (index === 0) {
|
|
firstInvalidField = field;
|
|
}
|
|
|
|
// If field is in a collapsed section, expand it
|
|
const nestedContent = field.closest('.nested-content');
|
|
if (nestedContent && nestedContent.classList.contains('hidden')) {
|
|
// Find the toggle button for this section
|
|
const sectionId = nestedContent.id;
|
|
if (sectionId) {
|
|
// Try multiple selectors to find the toggle button
|
|
const toggleBtn = document.querySelector(`button[aria-controls="${sectionId}"], button[onclick*="${sectionId}"], [data-toggle-section="${sectionId}"]`) ||
|
|
nestedContent.previousElementSibling?.querySelector('button');
|
|
if (toggleBtn && toggleBtn.onclick) {
|
|
toggleBtn.click(); // Expand the section
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Focus and scroll to first invalid field after a brief delay
|
|
// (allows collapsed sections to expand first)
|
|
setTimeout(() => {
|
|
if (firstInvalidField) {
|
|
firstInvalidField.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
firstInvalidField.focus();
|
|
}
|
|
}, 200);
|
|
|
|
// Show error notification with details
|
|
if (errors.length > 0) {
|
|
// Format error message nicely
|
|
const errorList = errors.slice(0, 5).join('\n'); // Show first 5 errors
|
|
const moreErrors = errors.length > 5 ? `\n... and ${errors.length - 5} more error(s)` : '';
|
|
const errorMessage = `Validation failed:\n${errorList}${moreErrors}`;
|
|
|
|
showNotification(errorMessage, 'error');
|
|
|
|
// Also log to console for debugging
|
|
console.error('Form validation errors:', errors);
|
|
}
|
|
|
|
// Report validation failure to browser (shows native validation tooltips)
|
|
form.reportValidity();
|
|
|
|
return false; // Prevent form submission
|
|
}
|
|
|
|
return true; // Validation passed
|
|
};
|
|
|
|
/**
|
|
* Reports a plugin config save. Called from the config form's
|
|
* hx-on::after-request in partials/plugin_config.html. Re-enables
|
|
* the submit button and shows the server's message as a success or
|
|
* error notification. Returns nothing.
|
|
*/
|
|
window.handleConfigSave = function(event, pluginId) {
|
|
const btn = event.target.querySelector('[type=submit]');
|
|
if (btn) btn.disabled = false;
|
|
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
// Check if request was successful (2xx status codes)
|
|
if (status >= 200 && status < 300) {
|
|
const data = xhrJson(xhr);
|
|
showNotification((data && data.message) || 'Configuration saved successfully!', 'success');
|
|
} else {
|
|
// Request failed - log detailed error information
|
|
console.error('Config save failed:', {
|
|
status: status,
|
|
statusText: xhr?.statusText,
|
|
responseText: xhr?.responseText
|
|
});
|
|
|
|
const errorData = xhrJson(xhr);
|
|
let errorMessage = errorData
|
|
? (errorData.message || errorData.details || 'Failed to save configuration')
|
|
: ((xhr && xhr.statusText) || 'Failed to save configuration');
|
|
if (errorData && errorData.validation_errors) {
|
|
errorMessage += ': ' + errorData.validation_errors.join(', ');
|
|
}
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Reports the enable/disable switch on a plugin's config tab. Called
|
|
* from the switch's hx-on::after-request in
|
|
* partials/plugin_config.html. On success it relabels the switch; on
|
|
* failure it flips the checkbox back. Returns nothing.
|
|
*/
|
|
window.handleToggleResponse = function(event, pluginId) {
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
if (status >= 200 && status < 300) {
|
|
// Update UI in place instead of refreshing to avoid duplication
|
|
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
|
|
const label = checkbox?.nextElementSibling;
|
|
|
|
if (checkbox && label) {
|
|
const isEnabled = checkbox.checked;
|
|
label.textContent = isEnabled ? 'Enabled' : 'Disabled';
|
|
label.className = `ml-2 text-sm ${isEnabled ? 'text-green-600' : 'text-gray-500'}`;
|
|
}
|
|
|
|
const data = xhrJson(xhr);
|
|
showNotification((data && data.message) || 'Plugin status updated', 'success');
|
|
} else {
|
|
// Revert checkbox state on error
|
|
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
|
|
if (checkbox) {
|
|
checkbox.checked = !checkbox.checked;
|
|
}
|
|
|
|
const errorData = xhrJson(xhr);
|
|
showNotification((errorData && (errorData.message || errorData.details)) ||
|
|
'Failed to update plugin status', 'error');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Reports the Update button on a plugin's config tab. Called from its
|
|
* hx-on::after-request in partials/plugin_config.html. Shows the
|
|
* server's message. Returns nothing.
|
|
*/
|
|
window.handlePluginUpdate = function(event, pluginId) {
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
// Check if request was successful (2xx status)
|
|
if (status >= 200 && status < 300) {
|
|
// Try to parse the response to get the actual message from server
|
|
let message = 'Plugin updated successfully';
|
|
|
|
if (xhr && xhr.responseText) {
|
|
try {
|
|
const data = JSON.parse(xhr.responseText);
|
|
// Use the server's message, ensuring it says "update" not "save"
|
|
message = data.message || message;
|
|
// Ensure message is about updating, not saving
|
|
if (message.toLowerCase().includes('save') && !message.toLowerCase().includes('update')) {
|
|
message = message.replace(/save/i, 'update');
|
|
}
|
|
} catch (e) {
|
|
// If parsing fails, use default message
|
|
console.warn('Could not parse update response:', e);
|
|
}
|
|
}
|
|
|
|
showNotification(message, 'success');
|
|
} else {
|
|
console.error('Plugin update failed:', {
|
|
status: status,
|
|
statusText: xhr?.statusText,
|
|
responseText: xhr?.responseText
|
|
});
|
|
|
|
// Try to parse error response for better error message
|
|
let errorMessage = 'Failed to update plugin';
|
|
if (xhr?.responseText) {
|
|
try {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorMessage;
|
|
} catch (e) {
|
|
// If parsing fails, use default
|
|
}
|
|
}
|
|
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Reloads a plugin's config tab from the server. Called from the
|
|
* Refresh button's onclick in partials/plugin_config.html. Clicks
|
|
* within a second of a reload are ignored. Returns nothing.
|
|
*/
|
|
window.refreshPluginConfig = function(pluginId) {
|
|
if (window.pluginConfigRefreshInProgress.has(pluginId)) {
|
|
return;
|
|
}
|
|
|
|
const container = document.getElementById(`plugin-config-${pluginId}`);
|
|
if (container && window.htmx) {
|
|
window.pluginConfigRefreshInProgress.add(pluginId);
|
|
|
|
container.innerHTML = '';
|
|
window.htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
|
|
target: container,
|
|
swap: 'innerHTML'
|
|
});
|
|
|
|
setTimeout(() => {
|
|
window.pluginConfigRefreshInProgress.delete(pluginId);
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
// Run / Stop on-demand buttons on a plugin's config tab (onclick in
|
|
// partials/plugin_config.html); plugins_manager.js implements both.
|
|
window.runPluginOnDemand = function(pluginId) {
|
|
window.openOnDemandModal(pluginId);
|
|
};
|
|
|
|
window.stopOnDemand = function() {
|
|
window.requestOnDemandStop({});
|
|
};
|
|
|
|
async function updatePlugin(pluginId) {
|
|
try {
|
|
showNotification(`Updating ${pluginId}...`, 'info');
|
|
|
|
const response = await fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
// Refresh the plugin list
|
|
const appComponent = window.getApp();
|
|
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
|
|
await appComponent.loadInstalledPlugins();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error updating plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
|