mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
* chore(web): remove dead legacy client-side plugin-config generator (~2,300 lines) Plugin config forms have been rendered server-side (plugin_config.html via GET /partials/plugin-config/<id>) since the HTMX migration; the old client-side generator survived as unreachable code. Verified dead by call graph, not by naming: showPluginConfigModal and showGithubTokenInstructions have zero callers anywhere in templates or JS, and everything removed here is reachable only from those two roots. Removed: - plugins_manager.js: showPluginConfigModal, generatePluginConfigForm, generateFormFromSchema, generateFieldHtml, generateSimpleConfigForm, handlePluginConfigSubmit, the modal's JSON-editor view (initJsonEditor, switchPluginConfigView, syncFormToJson/JsonToForm, saveConfigFromJsonEditor, resetPluginConfigToDefaults, displayValidationErrors, closePluginConfigModal, savePluginConfiguration, currentPluginConfigState), their exclusive helpers (getSchemaPropertyType, escapeCssSelector, dotToNested, collectBooleanFields, normalizeFormDataForConfig, flattenConfig, loadCustomHtmlWidget), the orphaned-modal cleanup block, the modal's listener wiring, and the never-invoked showGithubTokenInstructions/closeInstructionsModal pair. - plugins.html: the #plugin-config-modal markup those functions drove. - base.html: the deprecated pluginConfigData() component and the window.PluginConfigHelpers shim (only ever called by pluginConfigData). Deliberately kept, verified still live: - renderArrayObjectItem, getSchemaProperty, escapeHtml/escapeAttribute (window-exposed for the top-level array-of-objects handlers the server-rendered form uses), toggleNestedSection, addKeyValuePair/ addArrayObjectItem families, executePluginAction, and window.currentPluginConfig = null init (file-upload.js and executePluginAction read it, optional-chained). - app()'s internal generateConfigForm/generateSimpleConfigForm methods in base.html: unreachable now but embedded in the live Alpine component; excising methods from a live object is deferred to keep this change zero-risk. Validation: every deletion seam inspected line-by-line; Jinja parse of both templates passes; repo-wide sweep confirms zero remaining references to any deleted function or element id (deleted ranges contained no Jinja tags). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): mobile navigation drawer + responsive CSS gap fixes Phones previously got the desktop layout squeezed: ~12 system tabs plus one tab per installed plugin wrapped into many rows of small pill buttons, and the header's settings-search and system-stats widgets were dropped entirely (hidden below their breakpoints, never relocated). - Off-canvas nav drawer below md: the existing nav markup (system tab row + #plugin-tabs-row, including dynamically injected plugin tabs) is wrapped in a #site-nav container that CSS repositions into a slide-in drawer on small screens. Same DOM nodes, same @click handlers, nothing duplicated. Tabs become full-width rows with 44px+ touch targets. A hamburger button (md:hidden) in the header and a backdrop toggle the new mobileNavOpen Alpine state (added to both app() definitions, mirroring activeTab). Clicking any tab, a search result, or the backdrop closes the drawer. At md+ hard CSS guards make all drawer styles inert - desktop renders exactly as before. - Header widgets relocated, not hidden: placeHeaderWidgets() in app.js moves the #settings-search-wrap and #system-stats nodes (same elements, listeners intact - both are looked up by id from SSE/search code, so they must never be duplicated) into the drawer below md and back into the header above it, via a matchMedia listener. - Fixed 13 breakpoint utility classes that templates referenced but app.css never defined (sm:block, sm:grid-cols-2, sm:text-sm, md:block, md:w-auto, lg:block, lg:flex, lg:w-64, xl:grid-cols-2/3, 2xl:grid-cols-2/3/4). This was a live bug: 'hidden sm:block' on the search box and 'hidden lg:flex' on the stats meant BOTH were invisible at every screen width. Audit method (repeatable): diff classes used in templates vs defined in app.css. - Mobile modal sizing: one global rule caps .modal-content at 95vw/90vh with internal scroll below 640px - covers every modal without per-template changes. - Horizontal-scroll affordance: pure-CSS edge-fade shadows on .overflow-x-auto containers (scrolling-shadows technique), plus larger in-table touch targets below md. Validation: breakpoint used-vs-defined audit now returns zero gaps; Jinja parse of base.html passes; all changes to desktop behavior are additive (new utilities) or scoped inside max-width media queries. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): x-advanced schema flag groups plugin config fields under a collapsed Advanced Settings section Plugin config pages show every schema property at equal visual priority, which overwhelms first-time users. Plugin authors can now add "x-advanced": true to any flat (non-object) property in config_schema.json to move it into one collapsed "Advanced Settings (N)" section rendered after the basic fields - progressive disclosure with zero loss of control. Implementation: the main render loop in plugin_config.html splits ordered properties into basic/advanced tiers; the advanced group reuses the exact .nested-section/.nested-content/toggleSection() shell that nested object sections already use, so the settings search's expand-on-match behavior works on advanced fields with no JS changes. Object-type properties ignore the flag (they already render as their own collapsible sections). No backend change needed: jsonschema ignores unknown x-* keywords exactly as it does for x-widget/x-propertyOrder. Documented in docs/widget-guide.md alongside the other x-* extensions. Validation (rendered with real Jinja, not just parsed): - synthetic schema with 2 advanced fields: basic fields render before the section, advanced inside the collapsed shell, count badge correct, x-advanced on an object property correctly ignored - schema without any x-advanced: output is identical to the pre-change template (whitespace-normalized diff against git HEAD's version) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): Display Settings basic/advanced split + live total-resolution readout The Hardware Configuration card showed ~17 fields at equal priority; a new user only needs 7 of them to get a correctly-sized, correctly-colored image (rows, cols, chain_length, parallel, brightness, hardware_mapping, led_rgb_sequence). The other 10 (multiplexing, panel_type, row_address_type, gpio_slowdown, rp1_rio, scan_mode, pwm_bits, pwm_dither_bits, pwm_lsb_nanoseconds, limit_refresh_rate_hz) now live in a collapsed "Advanced Hardware Settings" section using the same nested-section shell as plugin config forms, so toggleSection() and settings-search auto-expand work unchanged. led_rgb_sequence moved up beside brightness/hardware_mapping (2-col grid became 3-col). No field was removed or renamed; the form still posts the same names to /api/v3/config/main. Also adds a live "Your display: W x H pixels" readout under the four sizing fields (width = cols x chain_length, height = rows x parallel - the exact math the chain-length tooltip describes in prose), recomputed client-side on every input event, no round-trip. Deviation from plan, deliberate: disable_hardware_pulsing / inverse_colors / show_refresh_rate stay in their separate "Display Options" card rather than moving across cards - relocating fields between form sections risks regressions for no decluttering gain in the card users complained about. Validation (real Jinja render): all 17 hardware fields present exactly once, basic fields render before the advanced section and the 10 advanced fields inside it, div count balanced (71/71), readout + recompute script present. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): plugin install auto-enables + persistent restart nudge Getting a plugin onto the display used to take three disconnected manual steps: install from the store, flip its enable toggle, then restart the display service - with no in-UI hint that steps 2 and 3 were needed (only docs/GETTING_STARTED.md mentions it). - installPlugin() now enables the plugin immediately on successful install (owner-confirmed behavior change: always auto-enable, no opt-out; users who don't want it running toggle it off as before), then shows a persistent toast ("... restart the display to show it") with an inline "Restart Now" button wired to the existing restartDisplay() - the same function the three existing Restart Display buttons call. - notification.js: show() accepts optional { actionLabel, onAction } to render one inline action button per toast. Callbacks are stored per notification id and cleaned up on dismiss; a new triggerAction() public method runs the callback and dismisses. The global showNotification() shorthand now forwards a full options object as its second argument (legacy type-string calls unchanged). Scope note: applies to the plugin store's install path (window.installPlugin). The custom-registry install path keeps its existing behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): dismissible Getting Started checklist on Overview New users land on a dense multi-tab dashboard with no suggested order of operations (the only guided flow is the WiFi captive portal). This adds a non-gating checklist card at the top of Overview with five steps, each a deep link that switches to the right tab (and closes the mobile nav drawer): 1. Set panel size -> Display tab (done: rows/cols/chain_length > 0) 2. Set timezone/location -> General tab (done: differs from template defaults America/New_York / Tampa) 3. Install a plugin -> Plugins tab (done: /api/v3/plugins/installed non-empty) 4. Enable a plugin -> Plugins tab (done: any installed plugin enabled) 5. Configure it -> Plugins tab (done: first enabled plugin has >=1 saved value differing from its schema defaults) Steps 1-2 are computed server-side in Jinja from main_config (already in the partial's context); 3-5 client-side from existing endpoints. No new backend state: dismissal persists in localStorage (mirroring the reconciliation banner's sessionStorage pattern one section up); deep links use the same _x_dataStack app-data access as settings-search.js. Disclosed heuristic limit: values left at legitimate defaults (a user actually in Tampa) read as "not done". Validation: real Jinja render across 3 config variants confirms the server-side done-flags flip correctly; div balance intact; /plugins/config response shape (config dict directly in .data) verified against api_v3.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(display): drag-and-drop plugin rotation order for the primary display mode The primary rotation's order was invisible and unconfigurable: modes are registered in parallel-load COMPLETION order, so rotation order actually varied between restarts. Only the niche Vegas Scroll mode had a working order UI. This adds real, persisted ordering end to end: Backend: - config.template.json: new display.plugin_rotation_order (default [], fully backward compatible). - display_controller.py: _apply_plugin_rotation_order() rebuilds available_modes grouped by plugin per the configured list (each plugin's modes keep their declared order; unlisted plugins follow in existing relative order; empty config = exact no-op). Applied at startup after parallel load and after live enable/disable reconcile (before the existing _resync_mode_index_after_change, which preserves the current mode). Mirrors vegas_mode get_ordered_plugins() semantics. - api_v3.py save_main_config: accepts plugin_rotation_order as a JSON array (same parse/guard pattern as vegas_plugin_order). Frontend: - New shared widget static/v3/js/widgets/plugin-order-list.js: the Vegas section's drag-and-drop list factored out verbatim (native HTML5 drag events, saved-order-first rendering, hidden-input JSON sync), parameterized by container/order-input/optional exclude-checkbox/badge. - display.html: Vegas section now calls the shared module; its ~130-line inline copy of the same logic is deleted. - durations.html: new "Rotation Order" card above the durations grid using the same module, posting plugin_rotation_order with the existing form. Deviation from plan, deliberate: durations stay as their own mode-keyed grid rather than inline in the drag rows - verified display_durations keys are MODE names (display_controller.py resolves duration per mode_key), not plugin ids, and one plugin can own several modes, so the planned 1:1 inline pairing was wrong. Validation: py_compile on both Python files; _apply_plugin_rotation_order unit-tested standalone (configured order applied, empty-config no-op, unknown ids skipped - 3/3); both templates render with balanced divs, the hidden input carries the saved order, and the old inline implementation is confirmed gone; config.template.json parses. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): serve the interface at / — /v3 kept as a legacy alias The user-visible URL no longer carries the interface version: the pages blueprint is now registered un-prefixed (primary) AND at /v3 (second registration, name='pages_v3_legacy'), so: - http://<device>/ serves the interface directly (the old @app.route('/') redirect is removed — the blueprint's own index takes its place) - every existing /v3/... bookmark and all the hardcoded /v3/partials/... fetches in templates/JS keep working verbatim through the alias mount — zero template/JS churn, zero broken links - url_for('pages_v3.*') resolves against the primary registration, so all server-side redirects (captive portal detection endpoints) now emit un-prefixed URLs - the AP-mode captive-portal allowlist learned the un-prefixed page paths (/setup, /partials/, /settings/, /plugin-ui/) so setup-mode requests don't redirect-loop - /api/v3 and the templates/v3, static/v3 directories are deliberately untouched (internal, invisible to users; owner-confirmed scope) Validation: dual registration mechanics tested against real Flask (test client): /, /v3, /v3/ redirect, partials and /setup reachable on both mounts, url_for yields un-prefixed paths; py_compile passes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * perf(web): stream the preview PNG raw instead of PIL decode + re-encode display_preview_generator() opened each changed snapshot with PIL and re-encoded it to PNG just to base64 it — but /tmp/led_matrix_preview.png already IS a PNG, written atomically by the display service (tmp file + os.replace in display_manager.py), so a partially-written file can never be observed. Read the bytes and base64 them directly: identical payload (front-end consumes data:image/png;base64 — verified in base.html), one full image decode+encode per frame less on the same Pi that's driving the matrix. The existing mtime skip and viewer-marker throttling are unchanged (they already covered the "skip unchanged frames" concern). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * perf(web): gzip response compression via flask-compress The interface ships a ~5,000-line HTML shell and >20k lines of JS uncompressed; on phone/WiFi that dominates load time. Flask-Compress gzips/brotlis compressible responses transparently. - Optional dependency, same graceful pattern as flask-limiter: missing package = uncompressed responses, no crash. - SSE safety verified empirically against the real package (1.24): an actual streamed text/event-stream response comes back with no Content-Encoding while a large HTML response gzips — the display preview / stats / logs streams are unaffected. - Added flask-compress>=1.14 to web_interface/requirements.txt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * perf(web): gate verbose console logging behind the existing pluginDebug switch plugins_manager.js, base.html's inline scripts, and app.js emitted 198 console.log calls in production - including per-interaction [DEBUG] dumps - costing main-thread time and drowning real errors in noise. - New window.debugLog() gate defined in base.html's first inline script (before any other script runs): forwards to console.log only when localStorage.pluginDebug === 'true' - the SAME switch plugins_manager.js already used for its _PLUGIN_DEBUG_EARLY logs, so existing debug workflow docs stay valid. Exposed as window.LEDMATRIX_DEBUG for other scripts. - Mechanically rewrote console.log( -> debugLog( in plugins_manager.js (127), base.html (64), app.js (7). Verified no occurrences lived inside string literals before rewriting; console.error/console.warn untouched. - app.js's no-Alpine showNotification fallback restored to console.info - it's a user-facing last resort, not debug output. Both load paths are safe: the gate is the first inline <script> in <head>, and every rewritten file loads deferred after it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * perf(web): vendor CDN assets locally — LAN-speed loads, fully offline-capable Font Awesome, CodeMirror, and htmx were fetched from cdnjs/unpkg on every fresh page load, adding third-party round-trips on a device that often lives on a local network (and htmx was local-only in AP mode, meaning two different loading behaviors to reason about). - Vendored pinned copies under static/v3/vendor/: Font Awesome 6.0.0 (css/all.min.css + the 8 webfonts it references relatively) and CodeMirror 5.65.2 (core, javascript mode, closebrackets/matchbrackets addons, base + monokai css) - ~1.1 MB total, exact versions the CDN tags pinned. - htmx + sse + json-enc extensions now load from the existing local copies (verified 1.9.10, matching the CDN pin) on EVERY network, not just AP mode; the pinned CDN copies remain as a one-shot rescue fallback, mirroring the pattern Alpine already used. The convoluted isAPMode source-flipping logic collapses away. - Dropped the CDN preconnect/dns-prefetch hints (no longer on the critical path). - Fixed a latent bug while relinking CodeMirror: the loader requested mode/json/json.min.js, which does not exist on cdnjs (HTTP 404 verified) - it 404'd on every JSON-editor open. JSON highlighting comes from the javascript mode; the phantom entry is removed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * perf(web): extract 3,850 lines of inline JS from base.html to cacheable static files base.html shipped ~4,200 lines of inline JavaScript inside the HTML document, re-downloaded and re-parsed on every page load (gzip helps the transfer, but inline scripts can never be browser-cached). The four largest blocks - none containing any Jinja syntax, verified by scanning every inline block for {{ }} / {% %} - now live as static files served with the app's existing mtime-versioned immutable caching: - js/htmx-config.js (246 lines): HTMX swap/script-execution config, toggleSection helpers - js/app-early.js (346 lines): early helpers + the app() stub that must precede Alpine init - js/app-shell.js (2,997 lines): SSE wiring + the full Alpine app() implementation and tab logic - js/custom-feeds-helpers.js (262 lines): custom-feeds table helpers Each replacement <script src> is CLASSIC (no defer/async) at the exact position of the inline block it replaces - identical execution timing and DOM visibility to inline scripts, so relative ordering with the deferred scripts and with each other is unchanged. base.html drops from ~4,940 to 1,079 lines. Validation: extraction proven lossless by programmatically reassembling the four files back into the template and comparing against git HEAD - byte-for-byte identical. Jinja parse passes; script open/close tags balanced (53/53, after excluding a literal "<script>" inside an HTML comment). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(web): size the live preview from the PNG's real dimensions, not config The initial SSE render sized the preview image (and both overlay canvases) from the server-reported config dimensions (cols x chain_length, rows x parallel), while the scale slider's re-render path sized from img.naturalWidth/naturalHeight. Whenever the snapshot PNG's actual size disagrees with the config (stale config, display service not restarted after a hardware change), the initial render stretched the image at a fractional ratio - blurry despite image-rendering: pixelated - and touching the scale slider "fixed" it. Reported live on the devpi test rig. Both paths now size from the loaded image's natural dimensions inside img.onload (which also removes a transient wrong-size flash between src assignment and load). The meta label now reports the true snapshot size. The preview card also gets overflow-x-auto so on narrow screens a wide preview scrolls at its exact pixel-perfect size instead of being squeezed into the viewport (fractional downscaling of pixel art also reads as blur). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): fold Display Options into the advanced dropdown; Vegas above Double-Sided Owner-requested layout refinement of the Display Settings tab: - The "Display Options" card (disable_hardware_pulsing, inverse_colors, show_refresh_rate, use_short_date_format, Dynamic Duration) moves inside the collapsed advanced section, now titled "Advanced Hardware & Display Options (15)". Hidden form fields still submit with the form, and settings search still auto-expands the section on match, so nothing is lost - the tab just leads with the essentials. - The "Vegas Scroll Mode" section moves above "Double-Sided Display". New section order: Hardware (+ advanced dropdown) > Vegas Scroll > Double-Sided > Multi-Display Sync. Validation (real Jinja render): all 23 field names present exactly once, divs balanced (70/70), the five Display Options fields render inside the advanced section's bounds, and section markers confirm the new order. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): Getting Started items are manually checkable; card auto-hides when complete Two gaps reported from live testing on the devpi rig: 1. The timezone/location step never showed done for a user whose real timezone IS the shipped default (America/New_York) - the heuristic can only detect difference-from-default, not "user saved this". Clicking an item's checkbox now toggles it done manually (persisted per browser in localStorage), so any heuristic false-negative is one tap to clear. Clicking the item text still deep-links to its tab. 2. The card now hides itself automatically once every step is done (auto-detected or manually checked) - previously it stayed until the X was clicked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * chore(web): CI cleanup — declare debugLog global, fix entity-unescape order, drop v3 from UI branding - Add debugLog to the /* global */ headers of the six JS files that call it (defined in base.html's first inline script) — resolves the wall of "'debugLog' is not defined" ESLint errors failing the Codacy check. - Fix the two js/double-escaping CodeQL alerts in app-shell.js: the entity-unescape chains decoded & before </>, so a value containing a pre-escaped "&lt;" wrongly double-decoded to "<". & now decodes last (standard order). Pre-existing bug, made visible when the inline scripts moved into scannable .js files. - Page title / header drop the "- v3" suffix, matching the de-versioned user-facing URL. The remaining 7 CodeQL alerts are pre-existing patterns newly visible to scanning (CodeQL doesn't see inline template JS): 4 github.com/htmx.org URL-substring checks (the htmx ones match error-message text, not URLs — false positives in context) and 1 innerHTML XSS-through-DOM in the GitHub install flow. Triage/fix deferred to a focused follow-up rather than expanding this PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(web): add the missing nav tab for the Rotation & Durations page The durations partial (/partials/durations) has existed as a route with no nav tab and no content panel referencing it - an orphaned page. That made the new rotation-order UI unreachable through the interface (caught by the owner testing on the rig; my endpoint-level tests fetched the partial by URL and never noticed the missing entry point). - New "Rotation" tab (fa-rotate icon, verified present in the vendored FA 6.0.0 css) between Display and Backup & Restore, wired exactly like the other tabs (#durations-content + hx-get + loadtab; loadTabContent() is fully generic, so no JS changes needed). - Page heading updated from "Display Durations" to "Rotation & Durations" to match its content since the rotation-order card landed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(web): Rotation & Durations page lists every enabled plugin's screens The durations grid looped over display.display_durations, which nothing has ever populated (verified {} on a real production install) - so the page rendered no duration fields at all. Worse, its inputs posted bare mode names, which save_main_config's endswith('_duration') filter silently dropped: the page was broken in both directions, unnoticed because it was also unreachable (previous commit). - pages_v3._load_durations_partial now builds one entry per display mode of every ENABLED plugin via plugin_manager.get_plugin_display_modes() (falling back to the plugin id), overlaid with saved values, defaulting to the display controller's 30s. Grouped per plugin, sorted by name. Saved keys not owned by any enabled plugin stay visible under "Other saved entries" instead of vanishing. - durations.html renders the grouped inputs, named duration__<mode_key> (mode keys are arbitrary, so they can't use the *_duration suffix convention), with an explanatory empty state when no plugins are enabled. - api_v3.save_main_config accepts the new duration__<mode> fields and writes them into display.display_durations under the bare mode key - exactly what the display controller reads (display_durations.get(mode_key, 30)). Validation: py_compile both blueprints; Jinja render with 3 groups asserts grouped inputs, saved-value overlay, stale-entry group, empty state, and div balance. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): restart-pending banner, unsaved-changes guard, installable web app Three usability improvements from live testing feedback: - Restart-pending banner: every successful POST to /api/v3/config/main (display hardware, rotation/durations, general settings) now raises a persistent banner - "Configuration saved, restart the display to apply" - with a Restart Now button that posts restart_display_service directly. Backed by sessionStorage so it survives tab switches and reloads until restarted or dismissed. Plugin config saves are deliberately excluded: they apply live via the display process's config watcher. - Unsaved-changes guard: plugin config panels are Alpine x-if templates, so navigating away destroys the panel and revisiting re-fetches it - edits were silently discarded. Forms now mark themselves dirty on input (cleared on successful submit), a capture-phase click handler confirms before a lossy tab switch, and beforeunload guards full page unloads. System tabs (x-show, persistent DOM) are exempt - no false prompts. - Installable web app: manifest.json (standalone display, dark theme) + generated LED-grid icons (192/512 maskable + 180 apple-touch), linked from base.html. "Add to Home Screen" now yields an app-like fullscreen experience; no service worker, so zero behavioral risk. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * test(web): smoke tests + static-analysis audits for the web UI Guardrails so this branch's fix classes can't regress silently: - test_web_smoke.py (24 tests): boots the pages blueprint with the same dual registration app.py uses and asserts every page/partial returns 200 with its load-bearing markers (nav wiring, getting-started card, advanced section, rotation order card, per-mode duration inputs), the /v3 legacy alias serves everything, all critical static assets (incl. vendored fontawesome/codemirror, PWA manifest/icons) are served, durations group per plugin with the leftover bucket, and the advanced-hardware section really contains the tuning fields. Would have caught this session's unreachable-durations-page and orphaned-tab bugs instantly. - test_web_static_audit.py (3 tests): (1) every responsive utility class referenced in templates is actually defined in app.css - the silently-no-op class bug that left the header search box invisible at every width; (2) every url_for('static', ...) reference points to a real file; (3) any JS file calling the debugLog global declares it in a /* global */ header. All 40 web tests pass (24 + 3 new, 13 existing) under pytest + Flask. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): floating live preview + per-plugin "Preview on display", drawer a11y Preview-while-configuring: - Floating mini preview (fixed, bottom-right) available on every tab except Overview, fed from the same SSE display stream by updateDisplayPreview - no new connections. Collapses to a round toggle button; open/closed state persists in localStorage; hides on Overview where the full preview lives. - "Preview on display" button on every plugin config page header: runs that plugin on the real display for 60 seconds via the existing /display/on-demand/start API and opens the floating preview, closing the configure -> see-the-result loop. Drawer/nav accessibility: - aria-current="page" tracks the active tab (system + dynamic plugin tabs, matched via their Alpine @click expression), updated from the activeTab watcher so search deep-links and checklist navigation are covered too. - Escape closes the mobile drawer and returns focus to the hamburger; opening the drawer moves focus to its first tab. Validation: all 40 web tests pass; Jinja parse + div balance on both touched templates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(web): resolve Codacy findings — DOM building over innerHTML, Map callbacks, misc lint Verified each of the 27 reported findings against current code; all fixed except one rule class skipped with reason below. - app-early.js: plugin tab buttons are now built with createElement/ createTextNode instead of innerHTML template strings (icon class and name come from plugin manifests - semi-trusted input; the old code escaped the name but interpolated the icon class). Both construction sites. Also the forEach arrow no longer returns tab.remove()'s value. - plugin-order-list.js: rows, empty state, and error state all built with DOM APIs - the file no longer contains innerHTML at all (the now-unneeded escapeHtml/escapeAttr helpers are removed); MODE_LABELS is a Map so the vegas-mode lookup can't hit prototype properties. - notification.js: actionCallbacks is a Map (get/set/delete) instead of a plain object - resolves the object-injection-sink and dynamic-delete findings; triggerAction also type-checks the callback. - htmx-config.js: unused catch binding dropped; var -> const in the afterSettle handler; the swapped-<script> re-execution reads/writes textContent instead of innerHTML; the diagnostic form payload uses a null-prototype object so a field named __proto__ can't pollute. - custom-feeds-helpers.js DELETED (with its script tag): all three of its functions (addCustomFeedRow, removeCustomFeedRow, handleCustomFeedLogoUpload) are shadowed by the deferred widgets/custom-feeds.js window assignments, which always win at call time - the copies were dead even when they lived inline in base.html. This also resolves the unused-function and unused-variable findings there. Skipped: 4x "Non-serializable expression must be wrapped with $(...)" in app-early.js - that rule targets code crossing a browser-automation serialization boundary (e.g. page.evaluate); these are ordinary arrow functions in plain browser code with no such boundary. Validation: all 40 web tests pass (incl. the static-asset reference audit, which confirms no template still points at the deleted file); Jinja parse OK. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): update flow installs changed Python dependencies automatically The in-app updater (Update Now banner -> git_pull action) stashed, pulled, and purged plugins - but never touched Python dependencies. Any release adding a package (e.g. this branch's flask-compress, which lives in web_interface/requirements.txt) silently required an SSH session and a manual pip install that most users will never do. - git_pull now records HEAD before pulling; after a successful pull it diffs old..new and, if requirements.txt or web_interface/requirements.txt changed, installs exactly those via _pip_install_requirements - the same vetted root-visible sudo path the Tools-tab buttons use (with its existing graceful fallback when the sudo wrapper isn't configured). Results are appended to the update toast; a failure points the user at the Tools-tab button instead of failing the whole update. - install_base_requirements (Tools tab) now also installs web_interface/requirements.txt - previously it only covered the root file, so web-only dependencies were unreachable from the UI entirely. No install happens when the pull was already-up-to-date or when no requirements file changed, so routine updates stay fast. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(web): address CodeRabbit review — validation, a11y, perf, and privacy fixes Verified each finding against current code. Fixed: - api_v3: plugin_rotation_order is now strictly validated (JSON list of strings, 400 with a descriptive message otherwise) and popped from the payload before any further handling. - display_controller: _apply_plugin_rotation_order defensively ignores a non-list value (keeps the existing rotation, logs a warning) and drops non-string entries; new logs carry the [DisplayController] prefix. Unit-tested both defensive paths. - app.py: snapshot-read handler narrowed to OSError with debug logging; flask-compress ImportError now emits one structured warning with the install remedy. - htmx-config: the response-error logger prints form FIELD NAMES only - values (API keys, passwords) never reach the console. - plugin-order-list: saved order/exclusions normalized with Array.isArray (a saved "null" previously crashed .forEach); each row gained keyboard/touch-accessible move-up/move-down buttons (HTML5 drag events don't fire on most mobile browsers) that reorder and syncInputs() immediately alongside native drag. - app-shell: window.installedPlugins setter always takes the new list (same-ID metadata/enabled updates were silently dropped); tab rebuild stays gated on ID changes. LED dot renderer reads the frame with ONE getImageData call instead of one per pixel (~9,200/frame at 192x48). - plugins_manager: togglePlugin returns its request promise resolving the API outcome; the install flow now shows the "installed and enabled" toast (with Restart Now) only after enablement succeeds, and a warning without a restart offer when it fails. - a11y: hamburger aria-label flips Open/Close with drawer state; both Advanced-section toggle buttons declare aria-controls/aria-expanded and the shared toggleSection() keeps aria-expanded in sync; move buttons have per-plugin aria-labels. - Rotation/Vegas order-list bootstraps cap their retries (~5s) and show a reload hint instead of spinning forever; Alpine app-state lookups prefer [x-data="app()"] with a generic fallback. Skipped, with reasons: - executePluginAction arg order: caller (plugin_config.html) already passes (actionId, index, pluginId) matching the signature exactly. - generateFieldHtml XSS, entity-unescape blocks, dotToNested pollution, and "app.loadInstalledPlugins" in app-shell: all inside the legacy client-side config cluster whose entry points are shadowed by plugins_manager.js / replaced by server-rendered forms (zero live callers, verified) - queued for wholesale deletion in the follow-up rather than patching dead code. - custom-feeds-helpers.js findings (3): file was deleted in a prior commit. - console.error/warn override removal and afterSwap script re-execution removal: deliberate pre-existing workarounds every partial's inline init currently depends on; reworking them safely needs isolated testing (follow-up), and the error suppression is already double-gated (insertBefore AND htmx match). - "move durations bootstrap into a bundle": inline partial-scoped init is the established pattern for HTMX partials in this codebase. Validation: all 40 web tests pass; py_compile on all touched Python; all touched templates parse; rotation-order defensive paths unit-tested. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * chore(web): fix remaining real Codacy findings (2 of 6) - htmx-config.js: two more unused catch bindings dropped (optional catch binding), matching the earlier fix. - app-early.js: second forEach arrow (the stub updatePluginTabs copy) braced so the callback no longer returns tab.remove()'s value. The other 4 findings ("Non-serializable expression must be wrapped with $(...)") are deliberately NOT "fixed": that rule belongs to a browser-automation (WebdriverIO-style) lint context and is misfiring on ordinary arrow-function constants. Converting them to function declarations would look compliant but BREAK the code - all four arrows intentionally capture the enclosing Alpine component's `this` for the stub-to-full enhancement logic. The right remedy is disabling that pattern for this repo in Codacy's Code Patterns settings (or dismissing the four findings), not a code change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(web): floating preview shows a frame immediately on open + is resizable The floating preview opened empty and stayed empty until the display next CHANGED - the SSE stream only pushes frames on change, and the panel only consumed frames while already open, so the connection's initial frame (sent while the panel was closed) was dropped. Reported from mobile testing as "the button doesn't work". - updateDisplayPreview now caches the latest frame globally regardless of panel state; opening the panel populates the image from that cache instantly, then live frames take over. - Resizable: a size button cycles 192/256/384/512px presets (persisted per browser; works on touch), and desktop additionally gets a native drag handle (CSS resize: both). The image is fluid within the panel; on phones the panel is capped to the viewport width. The size icon (fa-up-right-and-down-left-from-center) is verified present in the vendored FA 6.0.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(web): stop duration fields leaking into config root; isolate per-file dependency installs; fix test fixture leak Verified each finding against current code. - api_v3 save_main_config: both duration blocks (*_duration suffix fields and the newer duration__<mode> fields) only READ from `data`, never removed the keys. The generic "remaining keys" merge later in the same function has no skip-list entry for either pattern, so every duration field was ALSO written a second time as a bogus top-level config key (e.g. "clock_duration": 30 and "duration__mlb_live": 42 sitting at config root, alongside the correct nested display.display_durations.<key>). Confirmed by tracing the full function. Fixed by popping each handled key from `data` (same pattern already used for plugin_rotation_order) and validating strictly: a non-integer duration now returns 400 with a message naming the offending field/mode instead of silently logging and moving on (for the *_duration fields, which previously had zero validation at all). - api_v3 dependency-install loops (git_pull's post-update sync and install_base_requirements): _pip_install_requirements can raise subprocess.TimeoutExpired or OSError (confirmed: install_requirements_file in permission_utils.py never catches either internally, despite its docstring's "never raises on non-zero exit" only covering return codes). Both loops previously let one file's exception either abort the whole try block (skipping the second requirements file entirely) or propagate uncaught. Each file's install is now in its own try/except, so a timeout or OSError on one file is recorded as a labeled failure and the loop continues to the next file. - test_web_smoke.py: the `client` fixture mutated the module-level pages_v3 Blueprint singleton's config_manager/plugin_manager directly with no teardown - since pages_v3 is shared across the whole pytest process (test_web_settings_ui.py touches the same attributes), this fixture's mocks could leak into whichever test ran next. Now saves the originals, yields the client, and restores them in a finally block. Validation: py_compile passes; all 40 web tests pass with the now-generator fixture. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(web): repair garbled Advanced Hardware section description An earlier sed-based text update concatenated the old and new copies of this description instead of replacing one with the other, leaving a duplicated sentence with the — entity broken into ".mdash;" (visible as literal "mdash;" text on the page). Restored to one clean sentence. Other findings from this review were already fixed in a prior commit (installedPlugins setter) or are confirmed dead code with zero live callers (executePluginAction/dotToNested/entity-unescape/generateFieldHtml, all reachable only from the two unused savePluginConfig copies in app-shell.js - grepped every template, no references) - same legacy cluster flagged in earlier review passes, still queued for a dedicated deletion follow-up rather than patched in place here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(web): remove redundant htmx:afterSwap script re-execution (was double-executing every partial's inline script) Re-verified this CodeRabbit finding, previously deferred as "needs isolated testing" - traced it to a confirmed, active bug rather than a style concern: htmx 1.9.10's own config defaults to allowScriptTags: true (confirmed in the vendored htmx.min.js, which itself contains the same clone-and-reinsert <script> mechanism internally). This means htmx ALREADY re-executes every <script> tag in swapped content by default, exactly like a browser navigating to a new page. The custom htmx:afterSwap listener in htmx-config.js did the identical clone-and-reinsert a SECOND time on top of htmx's own handling - so every inline <script> block in every HTMX-loaded partial (overview, display, durations, plugin config, etc. - most partials have one) executed twice per load. Confirmed safe to delete outright, not just narrow: grepped every hand-written JS file for a manual `dispatchEvent(... 'htmx:afterSwap' ...)` that might have relied on this handler for a non-htmx code path (e.g. the direct-fetch fallbacks like loadOverviewDirect) - none exists, so nothing depended on this listener specifically; htmx's native handling covers every real htmx-driven swap on its own. Left in place, unchanged: the console.error/console.warn global override a few lines up in the same file, which suppresses known-noisy HTMX-timing-race messages. That one is a legitimate anti-pattern too (broad substring matching can mask unrelated errors) but redesigning it needs care to preserve real diagnostics while still hiding the specific harmless races it targets - a scoped follow-up, not a same-day deletion like this confirmed-duplicate handler. Validation: all 27 fast web tests pass; JS brace/paren balance sanity checked (no local Node/browser available in this sandbox to execute the file directly - verify manually in-browser before merge). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * chore(display): add missing [DisplayController] prefix to the reconcile-complete log Re-verifying the full CodeRabbit findings list against current code surfaced one still-open item: the nitpick asked for the prefix on BOTH rotation-related log lines, but only "Applied plugin rotation order" got it in the earlier pass - "Plugin reconcile complete" was missed. No message/argument/level change, matching the finding's own scope. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(web): repair dead /v3/logs link on the display hardware-error banner The "Logs tab" link in the display-settings simulation-mode banner was a real <a href> to /v3/logs, but no such route has ever existed (log content is loaded client-side via activeTab, not a dedicated page route) - the link 404'd regardless of the /v3 prefix change. Switch it to the same activeTab-switching pattern the real nav uses. * fix(web): raise display Rows field max from 64 to 128 Cols already allowed up to 128; Rows was capped at 64, which rejects valid larger panel configurations (e.g. 128-row tile chains). No server-side schema enforces a rows max, so this was purely an overly-strict HTML input attribute. * fix(web): remove redundant htmx.org substring check flagged by CodeQL CodeQL flags .includes('htmx.org') as "incomplete URL substring sanitization" - a false positive here, since this string is only ever matched against console.error/warn message text to decide whether to suppress a known-harmless HTMX timing-race log line, not used for any URL-trust/redirect decision. The check was also redundant: 'htmx' is already a substring of 'htmx.org', so the plain .includes('htmx') check right next to it already covers every case the removed check did. * fix(web): restore htmx script re-execution timing that Alpine x-data depends on Removing the custom htmx:afterSwap script-reexecution handler (in a prior commit, as a "duplicate execution" cleanup) broke every partial whose Alpine x-data component function is defined by an inline <script> in that same partial (e.g. wifi.html's wifiSetup()) - confirmed live via browser console: "Alpine Expression Error: wifiSetup is not defined" on every field in the WiFi tab. Root cause: htmx's own native script execution runs during its "settle" phase (~20ms after swap, per htmx's own defaultSettleDelay), but Alpine's MutationObserver evaluates x-data on newly-inserted elements synchronously, right as the swap lands - before settle. So the inline <script> defining wifiSetup() was still un-run when Alpine tried to call it, and Alpine does not retry a failed x-data evaluation later once the function does become defined. Fix: re-execute swapped <script> tags ourselves on htmx:afterSwap (which fires synchronously, before settle, beating Alpine's observer), and disable htmx's own native script re-execution (htmx.config.allowScriptTags = false) so the same script doesn't also run a second time during settle - restoring correct timing without reintroducing the original double-execution bug. Also in this commit: - fix XSS: unescaped repoUrl in a title attribute in renderSavedRepositories - replace .includes('github.com') substring checks with real URL hostname validation (CodeQL: incomplete URL substring sanitization) * fix(web): wait for async plugin install to finish before auto-enabling it Confirmed live: installing hockey-scoreboard logged "installation queued" (success) immediately followed by "enabling it failed" with a 404 "Plugin not found" from /api/v3/plugins/toggle. /api/v3/plugins/install runs the actual clone + plugin-manager discovery asynchronously via an operation queue when one is configured - the response installPlugin() was checking only means the operation was queued, not that the plugin is installed yet. Calling togglePlugin() right after that response 404s because plugin_manager hasn't discovered the new plugin. Fix: reuse the same operation-polling mechanism uninstallPlugin() already has (generalized pollOperationStatus() to take onComplete/onFailed/onTimeout callbacks instead of hardcoding uninstall behavior) so installPlugin() waits for the operation to actually complete before enabling it. Falls back to enabling immediately when no operation_id is returned (direct/synchronous install path, no queue configured). * fix(web): resolve remaining valid findings from latest review pass - custom-feeds.js: fix asset-upload contract mismatch (field name "file" -> "files", response read from top-level "uploaded_files" not "data.files") - same bug already fixed in this file on a separate branch/PR (#420), which this branch never received since they're independent PRs off main - custom-feeds.js: add aria-label to the two icon-only "remove feed" buttons - custom-feeds.js: move file-input reset into .finally() so a failed upload doesn't leave the input stuck holding the file, blocking retry of the same file - app-shell.js: fix executePluginAction(pluginId, actionId) parameter order/count mismatch vs. its callers' (actionId, index, pluginId) - currently masked by plugins_manager.js's correct version overwriting this one at load time (classic vs. deferred script order), but worth fixing outright since it's an isolated, self-contained reassignment (not inside the Alpine app() object literal) and removes a latent footgun - overview.html: align Alpine-state resolution with settings-search.js's two-tier getAppData() (also check appEl.__x.$data, not just _x_dataStack) Verified already addressed by earlier passes (no change needed): plugin_rotation_order validation, DisplayController log prefixes, togglePlugin returning its promise for install-flow chaining, installedPlugins setter always updating state, mobile-nav aria-label, toggleSection aria-expanded sync, PluginOrderList bounded init retries (both display.html and durations.html), plugin-order-list.js Array.isArray validation, batched getImageData in the LED-dot preview renderer, app.py exception narrowing/ logging, form-submission log redaction. Confirmed dead code, skipped (unreachable - zero template/JS callers, verified via full-repo grep): dotToNested prototype-pollution hardening, generateFieldHtml HTML-injection hardening, and the HTML-entity-unescape block in JSON parsing - all three live only inside app-shell.js's two legacy savePluginConfig implementations (one Alpine-method, one standalone), neither of which any template or script calls. The real, live plugin-config path is server-rendered via GET /partials/plugin-config/<id>. Explicitly NOT reverted: the htmx:afterSwap script-execution listener. An earlier finding batch asked to remove it as "duplicate" htmx behavior; that was tried and reverted this session after live testing on hardware proved it broke every partial whose Alpine x-data depends on an inline <script> in the same partial (confirmed: WiFi tab hard-failed with "wifiSetup is not defined"). Removing it again would reintroduce that regression. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
3048 lines
170 KiB
JavaScript
3048 lines
170 KiB
JavaScript
/* global debugLog */
|
|
// SSE wiring + full Alpine app() implementation and tab logic
|
|
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
|
// Assign to window so reconnectSSE() in app.js can reach them.
|
|
window.statsSource = new EventSource('/api/v3/stream/stats');
|
|
window.displaySource = new EventSource('/api/v3/stream/display');
|
|
|
|
window.statsSource.onmessage = function(event) {
|
|
const data = JSON.parse(event.data);
|
|
updateSystemStats(data);
|
|
};
|
|
|
|
window.displaySource.onmessage = function(event) {
|
|
const data = JSON.parse(event.data);
|
|
updateDisplayPreview(data);
|
|
};
|
|
|
|
function _setConnectionStatus(connected, reconnecting) {
|
|
const el = document.getElementById('connection-status');
|
|
if (!el) return;
|
|
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;
|
|
|
|
// Named on window so reconnectSSE() in app.js can reattach them after
|
|
// replacing the EventSource instances.
|
|
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 + ')');
|
|
};
|
|
|
|
window.statsSource.addEventListener('open', window._statsOpenHandler);
|
|
window.statsSource.addEventListener('error', window._statsErrorHandler);
|
|
window.displaySource.addEventListener('error', window._displayErrorHandler);
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateSystemStats(data) {
|
|
// Update CPU in header
|
|
const cpuEl = document.getElementById('cpu-stat');
|
|
if (cpuEl && data.cpu_percent !== undefined) {
|
|
const spans = cpuEl.querySelectorAll('span');
|
|
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_percent + '%';
|
|
}
|
|
|
|
// Update Memory in header
|
|
const memEl = document.getElementById('memory-stat');
|
|
if (memEl && data.memory_used_percent !== undefined) {
|
|
const spans = memEl.querySelectorAll('span');
|
|
if (spans.length > 0) spans[spans.length - 1].textContent = data.memory_used_percent + '%';
|
|
}
|
|
|
|
// Update Temperature in header
|
|
const tempEl = document.getElementById('temp-stat');
|
|
if (tempEl && data.cpu_temp !== undefined) {
|
|
const spans = tempEl.querySelectorAll('span');
|
|
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C';
|
|
}
|
|
|
|
// Update Power (under-voltage / throttling) status in header + banner
|
|
updatePowerStatus(data.power);
|
|
|
|
// Update Overview tab stats (if visible)
|
|
const cpuUsageEl = document.getElementById('cpu-usage');
|
|
if (cpuUsageEl && data.cpu_percent !== undefined) {
|
|
cpuUsageEl.textContent = data.cpu_percent + '%';
|
|
}
|
|
|
|
const memUsageEl = document.getElementById('memory-usage');
|
|
if (memUsageEl && data.memory_used_percent !== undefined) {
|
|
memUsageEl.textContent = data.memory_used_percent + '%';
|
|
}
|
|
|
|
const cpuTempEl = document.getElementById('cpu-temp');
|
|
if (cpuTempEl && data.cpu_temp !== undefined) {
|
|
cpuTempEl.textContent = data.cpu_temp + '°C';
|
|
}
|
|
|
|
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'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
// Alpine.js app function - full implementation
|
|
function app() {
|
|
// If Alpine is already initialized, get the current component and enhance it
|
|
let baseComponent = {};
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
baseComponent = appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
|
|
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() {
|
|
// Prevent multiple initializations
|
|
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);
|
|
}
|
|
// Trigger content load when tab changes
|
|
this.$nextTick(() => {
|
|
this.loadTabContent(newTab);
|
|
});
|
|
});
|
|
|
|
// Load initial tab content
|
|
this.$nextTick(() => {
|
|
this.loadTabContent(this.activeTab);
|
|
if (typeof window.updateNavAriaCurrent === 'function') {
|
|
window.updateNavAriaCurrent(this.activeTab);
|
|
}
|
|
});
|
|
|
|
// Listen for plugin updates from pluginManager
|
|
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. data-loaded is stamped on success so the panel
|
|
// loads once; 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;
|
|
}
|
|
return htmx.ajax('GET', url, { target: contentEl, swap: swap })
|
|
.then(() => contentEl.setAttribute('data-loaded', 'true'))
|
|
.catch(() => {}) // leave unstamped on failure so it can retry
|
|
.finally(() => contentEl.removeAttribute('data-loading'));
|
|
};
|
|
|
|
if (typeof htmx !== 'undefined') {
|
|
load();
|
|
return;
|
|
}
|
|
|
|
// htmx is loaded from a CDN and may not be ready yet. Poll until it is,
|
|
// then load; if it never arrives, fall back to a direct fetch.
|
|
let tries = 0;
|
|
const timer = setInterval(() => {
|
|
if (typeof htmx !== 'undefined') {
|
|
clearInterval(timer);
|
|
load();
|
|
} else if (++tries > 100) { // ~10s
|
|
clearInterval(timer);
|
|
contentEl.removeAttribute('data-loading');
|
|
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
|
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
|
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
|
else if (tab === 'tools') {
|
|
fetch('/v3/partials/tools')
|
|
.then(r => {
|
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
|
return r.text();
|
|
})
|
|
.then(html => {
|
|
contentEl.innerHTML = html;
|
|
contentEl.setAttribute('data-loaded', 'true');
|
|
if (window.Alpine) window.Alpine.initTree(contentEl);
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load tools content:', err);
|
|
contentEl.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Tools. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
}, 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);
|
|
}
|
|
},
|
|
|
|
updatePluginTabs(retryCount = 0) {
|
|
debugLog('[FULL] updatePluginTabs called (retryCount:', retryCount, ')');
|
|
const maxRetries = 5;
|
|
|
|
// Debounce: Clear any pending update
|
|
if (this._updatePluginTabsTimeout) {
|
|
clearTimeout(this._updatePluginTabsTimeout);
|
|
}
|
|
|
|
// For first call or retries, execute immediately to ensure tabs appear quickly
|
|
if (retryCount === 0) {
|
|
// First call - execute immediately, then debounce subsequent calls
|
|
this._doUpdatePluginTabs(retryCount);
|
|
} else {
|
|
// Retry - execute immediately
|
|
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;
|
|
}
|
|
|
|
// Store the current plugin IDs for next comparison
|
|
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`);
|
|
|
|
// Always show the plugin tabs row (Plugin Manager should always be available)
|
|
debugLog('[FULL] Ensuring plugin tabs row is visible');
|
|
pluginTabsRow.style.display = 'block';
|
|
|
|
// Clear existing plugin tabs (except the Plugin Manager tab)
|
|
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
debugLog(`[FULL] Removing ${existingTabs.length} existing plugin tabs`);
|
|
existingTabs.forEach(tab => tab.remove());
|
|
|
|
// Add tabs for each installed plugin
|
|
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 only escapes
|
|
// <, >, &, not ", so attribute-context interpolation
|
|
// would be unsafe.
|
|
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);
|
|
|
|
// Insert before the closing </nav> tag
|
|
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() {
|
|
// Update active state of all plugin tabs when activeTab changes
|
|
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') {
|
|
// Use global notification widget
|
|
if (typeof window.showNotification === 'function') {
|
|
window.showNotification(message, type);
|
|
} else {
|
|
debugLog(`[${type.toUpperCase()}]`, message);
|
|
}
|
|
},
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
},
|
|
|
|
async refreshPlugins() {
|
|
await this.loadInstalledPlugins();
|
|
await this.searchPluginStore();
|
|
this.showNotification('Plugin list refreshed', 'success');
|
|
},
|
|
|
|
|
|
|
|
async loadPluginConfig(pluginId) {
|
|
debugLog('Loading config for plugin:', pluginId);
|
|
this.loading = true;
|
|
|
|
try {
|
|
// Load config, schema, and installed plugins (for web_ui_actions) in parallel
|
|
// Use batched API if available for better performance
|
|
let configData, schemaData, pluginsData;
|
|
|
|
if (window.PluginAPI && window.PluginAPI.batch) {
|
|
// PluginAPI.batch returns already-parsed JSON objects
|
|
try {
|
|
const results = await window.PluginAPI.batch([
|
|
{endpoint: `/plugins/config?plugin_id=${pluginId}`, method: 'GET'},
|
|
{endpoint: `/plugins/schema?plugin_id=${pluginId}`, method: 'GET'},
|
|
{endpoint: '/plugins/installed', method: 'GET'}
|
|
]);
|
|
[configData, schemaData, pluginsData] = results;
|
|
} catch (batchError) {
|
|
console.error('Batch API request failed, falling back to individual requests:', batchError);
|
|
// Fall back to individual requests
|
|
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
|
|
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
|
|
]);
|
|
configData = configResponse;
|
|
schemaData = schemaResponse;
|
|
pluginsData = pluginsResponse;
|
|
}
|
|
} else {
|
|
// Direct fetch returns Response objects that need parsing
|
|
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
|
|
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
|
|
]);
|
|
configData = configResponse;
|
|
schemaData = schemaResponse;
|
|
pluginsData = pluginsResponse;
|
|
}
|
|
|
|
if (configData && configData.status === 'success') {
|
|
this.config = configData.data;
|
|
} else {
|
|
console.warn('Config API returned non-success status:', configData);
|
|
// Set defaults if config failed to load
|
|
this.config = { enabled: true, display_duration: 30 };
|
|
}
|
|
|
|
if (schemaData && schemaData.status === 'success') {
|
|
this.schema = schemaData.data.schema || {};
|
|
} else {
|
|
console.warn('Schema API returned non-success status:', schemaData);
|
|
// Set empty schema as fallback
|
|
this.schema = {};
|
|
}
|
|
|
|
// Extract web_ui_actions from installed plugins and update plugin data
|
|
if (pluginsData && pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) {
|
|
// Update window.installedPlugins with fresh data (includes commit info)
|
|
// The setter will check if data actually changed before updating tabs
|
|
window.installedPlugins = pluginsData.data.plugins;
|
|
// Update Alpine.js app data
|
|
this.installedPlugins = pluginsData.data.plugins;
|
|
|
|
const pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId);
|
|
this.webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : [];
|
|
debugLog('[DEBUG] Loaded web_ui_actions for', pluginId, ':', this.webUiActions.length, 'actions');
|
|
debugLog('[DEBUG] Updated plugin data with commit info:', pluginInfo ? {
|
|
last_commit: pluginInfo.last_commit,
|
|
branch: pluginInfo.branch,
|
|
last_updated: pluginInfo.last_updated
|
|
} : 'plugin not found');
|
|
} else {
|
|
console.warn('Plugins API returned non-success status:', pluginsData);
|
|
this.webUiActions = [];
|
|
}
|
|
|
|
debugLog('Loaded config, schema, and actions for', pluginId);
|
|
} catch (error) {
|
|
console.error('Error loading plugin config:', error);
|
|
this.config = { enabled: true, display_duration: 30 };
|
|
this.schema = {};
|
|
this.webUiActions = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
generateConfigForm(pluginId, config, schema, webUiActions = []) {
|
|
// Safety check - if schema/config not ready, return empty
|
|
if (!pluginId || !config) {
|
|
return '<div class="text-gray-500">Loading configuration...</div>';
|
|
}
|
|
|
|
// Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering)
|
|
if (!this._configFormLogged || this._configFormLogged !== pluginId) {
|
|
debugLog('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions');
|
|
// Debug: Check if image_config.images has x-widget in schema
|
|
if (schema && schema.properties && schema.properties.image_config) {
|
|
const imgConfig = schema.properties.image_config;
|
|
if (imgConfig.properties && imgConfig.properties.images) {
|
|
const imagesProp = imgConfig.properties.images;
|
|
debugLog('[DEBUG] Schema check - image_config.images:', {
|
|
type: imagesProp.type,
|
|
'x-widget': imagesProp['x-widget'],
|
|
'has x-widget': 'x-widget' in imagesProp,
|
|
keys: Object.keys(imagesProp)
|
|
});
|
|
}
|
|
}
|
|
this._configFormLogged = pluginId;
|
|
}
|
|
if (!schema || !schema.properties) {
|
|
return this.generateSimpleConfigForm(config, webUiActions, pluginId);
|
|
}
|
|
|
|
// Helper function to get schema property by full key path
|
|
const getSchemaProperty = (schemaObj, keyPath) => {
|
|
if (!schemaObj || !schemaObj.properties) return null;
|
|
const keys = keyPath.split('.');
|
|
let current = schemaObj.properties;
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const k = keys[i];
|
|
if (!current || !current[k]) {
|
|
return null;
|
|
}
|
|
|
|
const prop = current[k];
|
|
// If this is the last key, return the property
|
|
if (i === keys.length - 1) {
|
|
return prop;
|
|
}
|
|
|
|
// If this property has nested properties, navigate deeper
|
|
if (prop && typeof prop === 'object' && prop.properties) {
|
|
current = prop.properties;
|
|
} else {
|
|
// Can't navigate deeper
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const generateFieldHtml = (key, prop, value, prefix = '') => {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
const description = prop.description || '';
|
|
let html = '';
|
|
|
|
// Debug: Log property structure for arrays to help diagnose file-upload widget issues
|
|
if (prop.type === 'array') {
|
|
// Also check schema directly as fallback
|
|
const schemaProp = getSchemaProperty(schema, fullKey);
|
|
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
|
|
|
|
debugLog('[DEBUG generateFieldHtml] Array property:', fullKey, {
|
|
'prop.x-widget': prop['x-widget'],
|
|
'prop.x_widget': prop['x_widget'],
|
|
'schema.x-widget': xWidgetFromSchema,
|
|
'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'),
|
|
'x-widget in prop': 'x-widget' in prop,
|
|
'all prop keys': Object.keys(prop),
|
|
'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null'
|
|
});
|
|
}
|
|
|
|
// Handle nested objects
|
|
if (prop.type === 'object' && prop.properties) {
|
|
const sectionId = `section-${fullKey.replace(/\./g, '-')}`;
|
|
const nestedConfig = value || {};
|
|
const sectionLabel = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
// Calculate nesting depth for better spacing
|
|
const nestingDepth = (fullKey.match(/\./g) || []).length;
|
|
const marginClass = nestingDepth > 1 ? 'mb-6' : 'mb-4';
|
|
|
|
html += `
|
|
<div class="nested-section border border-gray-300 rounded-lg ${marginClass}">
|
|
<button type="button"
|
|
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors"
|
|
onclick="toggleNestedSection('${sectionId}', event); return false;">
|
|
<div class="flex-1">
|
|
<h4 class="font-semibold text-gray-900">${sectionLabel}</h4>
|
|
${description ? `<p class="text-sm text-gray-600 mt-1">${description}</p>` : ''}
|
|
</div>
|
|
<i id="${sectionId}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
|
</button>
|
|
<div id="${sectionId}" class="nested-content collapsed bg-gray-50 px-4 py-4 space-y-3" style="max-height: 0; display: none;">
|
|
`;
|
|
|
|
// Recursively generate fields for nested properties
|
|
// Get ordered properties if x-propertyOrder is defined
|
|
let nestedPropertyEntries = Object.entries(prop.properties);
|
|
if (prop['x-propertyOrder'] && Array.isArray(prop['x-propertyOrder'])) {
|
|
const order = prop['x-propertyOrder'];
|
|
const orderedEntries = [];
|
|
const unorderedEntries = [];
|
|
|
|
// Separate ordered and unordered properties
|
|
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
|
|
const index = order.indexOf(nestedKey);
|
|
if (index !== -1) {
|
|
orderedEntries[index] = [nestedKey, nestedProp];
|
|
} else {
|
|
unorderedEntries.push([nestedKey, nestedProp]);
|
|
}
|
|
});
|
|
|
|
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
|
|
nestedPropertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
|
|
}
|
|
|
|
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
|
|
// Use config value if it exists and is not null (including false), otherwise use schema default
|
|
// Check if key exists in config and value is not null/undefined
|
|
const hasValue = nestedKey in nestedConfig && nestedConfig[nestedKey] !== null && nestedConfig[nestedKey] !== undefined;
|
|
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
|
|
const isNestedObject = nestedProp.type === 'object' && nestedProp.properties;
|
|
const nestedValue = hasValue ? nestedConfig[nestedKey] :
|
|
(nestedProp.default !== undefined ? nestedProp.default :
|
|
(isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : ''))));
|
|
|
|
// Debug logging for file-upload widgets
|
|
if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) {
|
|
debugLog('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp);
|
|
}
|
|
|
|
html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add extra spacing after nested sections to prevent overlap with next section
|
|
if (nestingDepth > 0) {
|
|
html += `<div class="mb-2"></div>`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
// Regular (non-nested) field
|
|
html += `<div class="form-group">`;
|
|
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${label}</label>`;
|
|
|
|
if (description) {
|
|
html += `<p class="text-sm text-gray-600 mb-2">${description}</p>`;
|
|
}
|
|
|
|
// Generate appropriate input based on type
|
|
if (prop.type === 'boolean') {
|
|
html += `<label class="flex items-center">`;
|
|
html += `<input type="checkbox" name="${fullKey}" ${value ? 'checked' : ''} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">`;
|
|
html += `<span class="ml-2 text-sm">Enabled</span>`;
|
|
html += `</label>`;
|
|
} else if (prop.type === 'number' || prop.type === 'integer' ||
|
|
(Array.isArray(prop.type) && (prop.type.includes('number') || prop.type.includes('integer')))) {
|
|
// Handle union types like ["integer", "null"]
|
|
const isUnionType = Array.isArray(prop.type);
|
|
const allowsNull = isUnionType && prop.type.includes('null');
|
|
const isInteger = prop.type === 'integer' || (isUnionType && prop.type.includes('integer'));
|
|
const isNumber = prop.type === 'number' || (isUnionType && prop.type.includes('number'));
|
|
const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : '';
|
|
const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : '';
|
|
const step = isInteger ? 'step="1"' : 'step="any"';
|
|
|
|
// For union types with null, don't show default if value is null (leave empty)
|
|
// This allows users to explicitly set null by leaving it empty
|
|
let fieldValue = '';
|
|
if (value !== undefined && value !== null) {
|
|
fieldValue = value;
|
|
} else if (!allowsNull && prop.default !== undefined) {
|
|
// Only use default if null is not allowed
|
|
fieldValue = prop.default;
|
|
}
|
|
|
|
// Ensure value respects min/max constraints
|
|
if (fieldValue !== '' && fieldValue !== undefined && fieldValue !== null) {
|
|
const numValue = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
|
|
if (!isNaN(numValue)) {
|
|
// Clamp value to min/max if constraints exist
|
|
if (prop.minimum !== undefined && numValue < prop.minimum) {
|
|
fieldValue = prop.minimum;
|
|
} else if (prop.maximum !== undefined && numValue > prop.maximum) {
|
|
fieldValue = prop.maximum;
|
|
} else {
|
|
fieldValue = numValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add placeholder/help text for null-able fields
|
|
const placeholder = allowsNull ? 'Leave empty to use current time (random)' : '';
|
|
const helpText = allowsNull && description && description.includes('null') ?
|
|
`<p class="text-xs text-gray-500 mt-1">${description}</p>` : '';
|
|
|
|
html += `<input type="number" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} placeholder="${placeholder}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
if (helpText) {
|
|
html += helpText;
|
|
}
|
|
} else if (prop.type === 'array') {
|
|
// AGGRESSIVE file upload widget detection
|
|
// For 'images' field in static-image plugin, always check schema directly
|
|
let isFileUpload = false;
|
|
let uploadConfig = {};
|
|
|
|
// Direct check: if this is the 'images' field and schema has it with x-widget
|
|
if (fullKey === 'images' && schema && schema.properties && schema.properties.images) {
|
|
const imagesSchema = schema.properties.images;
|
|
if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') {
|
|
isFileUpload = true;
|
|
uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {};
|
|
debugLog('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig);
|
|
}
|
|
}
|
|
|
|
// Fallback: check prop object (should have x-widget if schema loaded correctly)
|
|
if (!isFileUpload) {
|
|
const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget;
|
|
if (xWidgetFromProp === 'file-upload') {
|
|
isFileUpload = true;
|
|
uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {};
|
|
debugLog('[DEBUG] ✅ Detection via prop object');
|
|
}
|
|
}
|
|
|
|
// Fallback: schema property lookup
|
|
if (!isFileUpload) {
|
|
let schemaProp = getSchemaProperty(schema, fullKey);
|
|
if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) {
|
|
schemaProp = schema.properties.images;
|
|
}
|
|
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
|
|
if (xWidgetFromSchema === 'file-upload') {
|
|
isFileUpload = true;
|
|
uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {};
|
|
debugLog('[DEBUG] ✅ Detection via schema lookup');
|
|
}
|
|
}
|
|
|
|
// Debug logging for ALL array fields to diagnose
|
|
debugLog('[DEBUG] Array field check:', fullKey, {
|
|
'isFileUpload': isFileUpload,
|
|
'prop keys': Object.keys(prop),
|
|
'prop.x-widget': prop['x-widget'],
|
|
'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images),
|
|
'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null,
|
|
'uploadConfig': uploadConfig
|
|
});
|
|
|
|
if (isFileUpload) {
|
|
debugLog('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig);
|
|
// Use the file upload widget from plugins.html
|
|
// We'll need to call a function that exists in the global scope
|
|
const maxFiles = uploadConfig.max_files || 10;
|
|
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
|
|
const currentImages = Array.isArray(value) ? value : [];
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
const safePluginId = (uploadConfig.plugin_id || pluginId || 'static-image').toString().replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
|
|
html += `
|
|
<div id="${fieldId}_upload_widget" class="mt-1">
|
|
<!-- File Upload Drop Zone -->
|
|
<div id="${fieldId}_drop_zone"
|
|
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
|
ondragover="event.preventDefault()"
|
|
data-field-id="${fieldId}"
|
|
onclick="document.getElementById(this.dataset.fieldId + '_file_input').click()">
|
|
<input type="file"
|
|
id="${fieldId}_file_input"
|
|
multiple
|
|
accept="${allowedTypes.join(',')}"
|
|
style="display: none;"
|
|
data-field-id="${fieldId}"
|
|
onchange="window.handleFileSelect(event, this.dataset.fieldId)">
|
|
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
|
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)</p>
|
|
</div>
|
|
|
|
<!-- Uploaded Images List -->
|
|
<div id="${fieldId}_image_list" class="mt-4 space-y-2">
|
|
${currentImages.map((img, idx) => {
|
|
const imgSchedule = img.schedule || {};
|
|
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
|
|
let scheduleSummary = 'Always shown';
|
|
if (hasSchedule && window.getScheduleSummary) {
|
|
try {
|
|
scheduleSummary = window.getScheduleSummary(imgSchedule) || 'Scheduled';
|
|
} catch (e) {
|
|
scheduleSummary = 'Scheduled';
|
|
}
|
|
} else if (hasSchedule) {
|
|
scheduleSummary = 'Scheduled';
|
|
}
|
|
// Escape the summary for HTML
|
|
scheduleSummary = String(scheduleSummary).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
return `
|
|
<div id="img_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center space-x-3 flex-1">
|
|
<img src="/${(img.path || '').replace(/&/g, '&').replace(/"/g, '"')}"
|
|
alt="${(img.filename || '').replace(/"/g, '"')}"
|
|
class="w-16 h-16 object-cover rounded"
|
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
|
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
|
|
<i class="fas fa-image text-gray-400"></i>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-gray-900 truncate">${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</p>
|
|
<p class="text-xs text-gray-500">${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&')}</p>
|
|
<p class="text-xs text-blue-600 mt-1">
|
|
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2 ml-4">
|
|
<button type="button"
|
|
data-field-id="${fieldId}"
|
|
data-image-id="${img.id || ''}"
|
|
data-image-idx="${idx}"
|
|
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
|
|
class="text-blue-600 hover:text-blue-800 p-2"
|
|
title="Schedule this image">
|
|
<i class="fas fa-calendar-alt"></i>
|
|
</button>
|
|
<button type="button"
|
|
data-field-id="${fieldId}"
|
|
data-image-id="${img.id || ''}"
|
|
data-plugin-id="${safePluginId}"
|
|
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
|
|
class="text-red-600 hover:text-red-800 p-2"
|
|
title="Delete image">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- Schedule widget will be inserted here when opened -->
|
|
<div id="schedule_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
|
|
<!-- Hidden input to store image data -->
|
|
<input type="hidden" id="${fieldId}_images_data" name="${fullKey}" value="${JSON.stringify(currentImages).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')}">
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Regular array input
|
|
const arrayValue = Array.isArray(value) ? value.join(', ') : '';
|
|
html += `<input type="text" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
html += `<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>`;
|
|
}
|
|
} else if (prop.enum) {
|
|
html += `<select name="${fullKey}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
prop.enum.forEach(option => {
|
|
const selected = value === option ? 'selected' : '';
|
|
html += `<option value="${option}" ${selected}>${option}</option>`;
|
|
});
|
|
html += `</select>`;
|
|
} else if (prop.type === 'string' && prop['x-widget'] === 'file-upload') {
|
|
// File upload widget for string fields (e.g., credentials.json)
|
|
const uploadConfig = prop['x-upload-config'] || {};
|
|
const uploadEndpoint = uploadConfig.upload_endpoint || '/api/v3/plugins/assets/upload';
|
|
const maxSizeMB = uploadConfig.max_size_mb || 1;
|
|
const allowedExtensions = uploadConfig.allowed_extensions || ['.json'];
|
|
const targetFilename = uploadConfig.target_filename || 'file.json';
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
const hasFile = value && value !== '';
|
|
|
|
html += `
|
|
<div id="${fieldId}_upload_widget" class="mt-1">
|
|
<div id="${fieldId}_file_upload"
|
|
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
onclick="document.getElementById('${fieldId}_file_input').click()">
|
|
<input type="file"
|
|
id="${fieldId}_file_input"
|
|
accept="${allowedExtensions.join(',')}"
|
|
style="display: none;"
|
|
data-field-id="${fieldId}"
|
|
data-upload-endpoint="${uploadEndpoint}"
|
|
data-target-filename="${targetFilename}"
|
|
onchange="window.handleCredentialsUpload(event, this.dataset.fieldId, this.dataset.uploadEndpoint, this.dataset.targetFilename)">
|
|
<i class="fas fa-file-upload text-2xl text-gray-400 mb-2"></i>
|
|
<p class="text-sm text-gray-600" id="${fieldId}_status">
|
|
${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename}
|
|
</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})</p>
|
|
</div>
|
|
<input type="hidden" name="${fullKey}" value="${value || ''}" id="${fieldId}_hidden">
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Default to text input
|
|
const maxLength = prop.maxLength || '';
|
|
const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : '';
|
|
html += `<input type="text" name="${fullKey}" value="${value !== undefined ? value : ''}" ${maxLengthAttr} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
return html;
|
|
};
|
|
|
|
let formHtml = '';
|
|
// Get ordered properties if x-propertyOrder is defined
|
|
let propertyEntries = Object.entries(schema.properties);
|
|
if (schema['x-propertyOrder'] && Array.isArray(schema['x-propertyOrder'])) {
|
|
const order = schema['x-propertyOrder'];
|
|
const orderedEntries = [];
|
|
const unorderedEntries = [];
|
|
|
|
// Separate ordered and unordered properties
|
|
propertyEntries.forEach(([key, prop]) => {
|
|
const index = order.indexOf(key);
|
|
if (index !== -1) {
|
|
orderedEntries[index] = [key, prop];
|
|
} else {
|
|
unorderedEntries.push([key, prop]);
|
|
}
|
|
});
|
|
|
|
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
|
|
propertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
|
|
}
|
|
|
|
propertyEntries.forEach(([key, prop]) => {
|
|
// Skip the 'enabled' property - it's managed separately via the header toggle
|
|
if (key === 'enabled') return;
|
|
// Use config value if key exists and is not null/undefined, otherwise use schema default
|
|
// Check if key exists in config and value is not null/undefined
|
|
const hasValue = key in config && config[key] !== null && config[key] !== undefined;
|
|
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
|
|
const isNestedObject = prop.type === 'object' && prop.properties;
|
|
const value = hasValue ? config[key] :
|
|
(prop.default !== undefined ? prop.default :
|
|
(isNestedObject ? {} : (prop.type === 'array' ? [] : (prop.type === 'boolean' ? false : ''))));
|
|
formHtml += generateFieldHtml(key, prop, value);
|
|
});
|
|
|
|
// Add web UI actions section if plugin defines any
|
|
if (webUiActions && webUiActions.length > 0) {
|
|
debugLog('[DEBUG] Rendering', webUiActions.length, 'actions in tab form');
|
|
|
|
// Map color names to explicit Tailwind classes
|
|
const colorMap = {
|
|
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
|
|
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
|
|
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
|
|
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
|
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
|
|
};
|
|
|
|
formHtml += `
|
|
<div class="border-t border-gray-200 pt-4 mt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
|
<p class="text-sm text-gray-600 mb-4">${webUiActions[0].section_description || 'Perform actions for this plugin'}</p>
|
|
|
|
<div class="space-y-3">
|
|
`;
|
|
|
|
webUiActions.forEach((action, index) => {
|
|
const actionId = `action-${action.id}-${index}`;
|
|
const statusId = `action-status-${action.id}-${index}`;
|
|
const bgColor = action.color || 'blue';
|
|
const colors = colorMap[bgColor] || colorMap['blue'];
|
|
// Ensure pluginId is valid for template interpolation
|
|
const safePluginId = pluginId || '';
|
|
|
|
formHtml += `
|
|
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<h4 class="font-medium ${colors.text} mb-1">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
|
</h4>
|
|
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
|
</div>
|
|
<button type="button"
|
|
id="${actionId}"
|
|
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
|
data-plugin-id="${safePluginId}"
|
|
data-action-id="${action.id}"
|
|
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
|
</button>
|
|
</div>
|
|
<div id="${statusId}" class="mt-3 hidden"></div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
formHtml += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return formHtml;
|
|
},
|
|
|
|
generateSimpleConfigForm(config, webUiActions = [], pluginId = '') {
|
|
let actionsHtml = '';
|
|
if (webUiActions && webUiActions.length > 0) {
|
|
const colorMap = {
|
|
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
|
|
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
|
|
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
|
|
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
|
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
|
|
};
|
|
|
|
actionsHtml = `
|
|
<div class="border-t border-gray-200 pt-4 mt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
|
<div class="space-y-3">
|
|
`;
|
|
webUiActions.forEach((action, index) => {
|
|
const actionId = `action-${action.id}-${index}`;
|
|
const statusId = `action-status-${action.id}-${index}`;
|
|
const bgColor = action.color || 'blue';
|
|
const colors = colorMap[bgColor] || colorMap['blue'];
|
|
// Ensure pluginId is valid for template interpolation
|
|
const safePluginId = pluginId || '';
|
|
actionsHtml += `
|
|
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<h4 class="font-medium ${colors.text} mb-1">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
|
</h4>
|
|
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
|
</div>
|
|
<button type="button"
|
|
id="${actionId}"
|
|
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
|
data-plugin-id="${safePluginId}"
|
|
data-action-id="${action.id}"
|
|
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
|
</button>
|
|
</div>
|
|
<div id="${statusId}" class="mt-3 hidden"></div>
|
|
</div>
|
|
`;
|
|
});
|
|
actionsHtml += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="form-group">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
|
|
<input type="number" name="display_duration" value="${Math.max(5, Math.min(300, config.display_duration || 30))}" min="5" max="300" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
|
<p class="text-sm text-gray-600 mt-1">How long to show this plugin's content</p>
|
|
</div>
|
|
${actionsHtml}
|
|
`;
|
|
},
|
|
|
|
// Helper function to get schema property type for a field path
|
|
getSchemaPropertyType(schema, path) {
|
|
if (!schema || !schema.properties) return null;
|
|
|
|
const parts = path.split('.');
|
|
let current = schema.properties;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (current && current[part]) {
|
|
if (i === parts.length - 1) {
|
|
return current[part];
|
|
} else if (current[part].properties) {
|
|
current = current[part].properties;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
// Helper function to escape CSS selector special characters
|
|
escapeCssSelector(str) {
|
|
if (typeof str !== 'string') {
|
|
str = String(str);
|
|
}
|
|
// Use CSS.escape() when available (handles unicode, leading digits, and edge cases)
|
|
if (typeof CSS !== 'undefined' && CSS.escape) {
|
|
return CSS.escape(str);
|
|
}
|
|
// Fallback to regex-based escaping for older browsers
|
|
// First, handle leading digits and whitespace (must be done before regex)
|
|
let escaped = str;
|
|
let hasLeadingHexEscape = false;
|
|
if (escaped.length > 0) {
|
|
const firstChar = escaped[0];
|
|
const firstCode = firstChar.charCodeAt(0);
|
|
|
|
// Escape leading digit (0-9: U+0030-U+0039)
|
|
if (firstCode >= 0x30 && firstCode <= 0x39) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
// Escape leading whitespace (space: U+0020, tab: U+0009, etc.)
|
|
else if (/\s/.test(firstChar)) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
}
|
|
|
|
// Escape special characters
|
|
escaped = escaped.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
|
|
|
// Escape internal spaces (replace spaces with \ ), but preserve space in hex escape
|
|
if (hasLeadingHexEscape) {
|
|
// Skip the first 6 characters (e.g., "\0030 ") when replacing spaces
|
|
escaped = escaped.slice(0, 6) + escaped.slice(6).replace(/ /g, '\\ ');
|
|
} else {
|
|
escaped = escaped.replace(/ /g, '\\ ');
|
|
}
|
|
|
|
return escaped;
|
|
},
|
|
|
|
async savePluginConfig(pluginId, event) {
|
|
try {
|
|
// Get the form element for this plugin
|
|
const form = event ? event.target : null;
|
|
if (!form) {
|
|
throw new Error('Form element not found');
|
|
}
|
|
const formData = new FormData(form);
|
|
const schema = this.schema || {};
|
|
|
|
// First, collect all checkbox states (including unchecked ones)
|
|
// Unchecked checkboxes don't appear in FormData, so we need to iterate form elements
|
|
const flatConfig = {};
|
|
|
|
// Process all form elements to capture all field states
|
|
for (let i = 0; i < form.elements.length; i++) {
|
|
const element = form.elements[i];
|
|
const name = element.name;
|
|
|
|
// Skip elements without names or submit buttons
|
|
if (!name || element.type === 'submit' || element.type === 'button') {
|
|
continue;
|
|
}
|
|
|
|
// Handle checkboxes explicitly (both checked and unchecked)
|
|
if (element.type === 'checkbox') {
|
|
// Check if this is a checkbox group (name ends with [])
|
|
if (name.endsWith('[]')) {
|
|
const baseName = name.slice(0, -2); // Remove '[]' suffix
|
|
if (!flatConfig[baseName]) {
|
|
flatConfig[baseName] = [];
|
|
}
|
|
if (element.checked) {
|
|
flatConfig[baseName].push(element.value);
|
|
}
|
|
} else {
|
|
// Regular checkbox (boolean)
|
|
flatConfig[name] = element.checked;
|
|
}
|
|
}
|
|
// Handle radio buttons
|
|
else if (element.type === 'radio') {
|
|
if (element.checked) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
// Handle select elements (including multi-select)
|
|
else if (element.tagName === 'SELECT') {
|
|
if (element.multiple) {
|
|
// Multi-select: get all selected options
|
|
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
|
|
flatConfig[name] = selectedValues;
|
|
} else {
|
|
// Single select: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
// Handle textarea
|
|
else if (element.tagName === 'TEXTAREA') {
|
|
// Textarea: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now process FormData for other field types
|
|
for (const [key, value] of formData.entries()) {
|
|
// Skip checkboxes - we already handled them above
|
|
// Use querySelector to reliably find element by name (handles dot notation)
|
|
const escapedKey = this.escapeCssSelector(key);
|
|
const element = form.querySelector(`[name="${escapedKey}"]`);
|
|
if (element && element.type === 'checkbox') {
|
|
// Also skip checkbox groups (name ends with [])
|
|
if (key.endsWith('[]')) {
|
|
continue; // Already processed
|
|
}
|
|
continue; // Already processed
|
|
}
|
|
// Skip multi-select - we already handled them above
|
|
if (element && element.tagName === 'SELECT' && element.multiple) {
|
|
continue; // Already processed
|
|
}
|
|
|
|
// Get schema property type if available
|
|
const propSchema = this.getSchemaPropertyType(schema, key);
|
|
const propType = propSchema ? propSchema.type : null;
|
|
|
|
// Handle based on schema type or field name patterns
|
|
if (propType === 'array') {
|
|
// Check if this is a file upload widget (JSON array in hidden input)
|
|
if (propSchema && propSchema['x-widget'] === 'file-upload') {
|
|
try {
|
|
// Unescape HTML entities that were escaped when setting the value
|
|
let unescapedValue = value;
|
|
if (typeof value === 'string') {
|
|
// Reverse the HTML escaping: " -> ", ' -> ', & -> &
|
|
unescapedValue = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/g, '&');
|
|
}
|
|
|
|
// Try to parse as JSON
|
|
const jsonValue = JSON.parse(unescapedValue);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
debugLog(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`);
|
|
} else {
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value);
|
|
// Not valid JSON, use empty array or try comma-separated
|
|
if (value && value.trim()) {
|
|
// Try to unescape and parse again
|
|
try {
|
|
const unescaped = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&');
|
|
const jsonValue = JSON.parse(unescaped);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e2) {
|
|
// If still fails, try comma-separated or empty array
|
|
const arrayValue = value.split(',').map(v => v.trim()).filter(v => v);
|
|
flatConfig[key] = arrayValue.length > 0 ? arrayValue : [];
|
|
}
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
}
|
|
} else {
|
|
// Regular array: convert comma-separated string to array
|
|
const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[key] = arrayValue;
|
|
}
|
|
} else if (propType === 'integer' || (Array.isArray(propType) && propType.includes('integer'))) {
|
|
// Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null)
|
|
if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) {
|
|
flatConfig[key] = ''; // Send empty string, backend will normalize to null
|
|
} else {
|
|
const numValue = parseInt(value, 10);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
}
|
|
} else if (propType === 'number' || (Array.isArray(propType) && propType.includes('number'))) {
|
|
// Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null)
|
|
if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) {
|
|
flatConfig[key] = ''; // Send empty string, backend will normalize to null
|
|
} else {
|
|
const numValue = parseFloat(value);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
}
|
|
} else if (propType === 'boolean') {
|
|
// Boolean from FormData (shouldn't happen for checkboxes, but handle it)
|
|
flatConfig[key] = value === 'on' || value === 'true' || value === true;
|
|
} else {
|
|
// String or other types
|
|
// Check if it's a number field by name pattern (fallback if no schema)
|
|
if (!propType && (key.includes('duration') || key.includes('interval') ||
|
|
key.includes('timeout') || key.includes('teams') || key.includes('fps') ||
|
|
key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) {
|
|
const numValue = parseFloat(value);
|
|
if (!isNaN(numValue)) {
|
|
flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue;
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unchecked checkboxes using schema (if available)
|
|
if (schema && schema.properties) {
|
|
const collectBooleanFields = (props, prefix = '') => {
|
|
const boolFields = [];
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
if (prop.type === 'boolean') {
|
|
boolFields.push(fullKey);
|
|
} else if (prop.type === 'object' && prop.properties) {
|
|
boolFields.push(...collectBooleanFields(prop.properties, fullKey));
|
|
}
|
|
}
|
|
return boolFields;
|
|
};
|
|
|
|
const allBoolFields = collectBooleanFields(schema.properties);
|
|
allBoolFields.forEach(key => {
|
|
// Only set to false if the field is completely missing from flatConfig
|
|
// Don't override existing false values - they're explicitly set by the user
|
|
if (!(key in flatConfig)) {
|
|
flatConfig[key] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert dot notation to nested object
|
|
const dotToNested = (obj) => {
|
|
const result = {};
|
|
for (const key in obj) {
|
|
const parts = key.split('.');
|
|
let current = result;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
if (!current[parts[i]]) {
|
|
current[parts[i]] = {};
|
|
}
|
|
current = current[parts[i]];
|
|
}
|
|
current[parts[parts.length - 1]] = obj[key];
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const config = dotToNested(flatConfig);
|
|
|
|
// Save to backend
|
|
const response = await fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
plugin_id: pluginId,
|
|
config: config
|
|
})
|
|
});
|
|
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch (e) {
|
|
console.error('Failed to parse JSON response:', e);
|
|
console.error('Response status:', response.status, response.statusText);
|
|
console.error('Response text:', await response.text());
|
|
throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
debugLog('Response status:', response.status, 'Response OK:', response.ok);
|
|
debugLog('Response data:', JSON.stringify(data, null, 2));
|
|
|
|
if (!response.ok || data.status !== 'success') {
|
|
let errorMessage = data.message || 'Failed to save configuration';
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
console.error('Validation errors:', data.validation_errors);
|
|
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
|
|
}
|
|
if (data.config_keys && data.schema_keys) {
|
|
console.error('Config keys sent:', data.config_keys);
|
|
console.error('Schema keys expected:', data.schema_keys);
|
|
const extraKeys = data.config_keys.filter(k => !data.schema_keys.includes(k));
|
|
const missingKeys = data.schema_keys.filter(k => !data.config_keys.includes(k));
|
|
if (extraKeys.length > 0) {
|
|
errorMessage += '\n\nExtra keys (not in schema): ' + extraKeys.join(', ');
|
|
}
|
|
if (missingKeys.length > 0) {
|
|
errorMessage += '\n\nMissing keys (in schema): ' + missingKeys.join(', ');
|
|
}
|
|
}
|
|
this.showNotification(errorMessage, 'error');
|
|
console.error('Config save failed - Full error response:', JSON.stringify(data, null, 2));
|
|
} else {
|
|
this.showNotification('Configuration saved successfully', 'success');
|
|
// Reload plugin config to reflect changes
|
|
await this.loadPluginConfig(pluginId);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving plugin config:', error);
|
|
this.showNotification('Error saving configuration: ' + error.message, 'error');
|
|
}
|
|
},
|
|
|
|
formatCommitInfo(commit, branch) {
|
|
// Handle null, undefined, or empty string
|
|
const commitStr = (commit && String(commit).trim()) || '';
|
|
const branchStr = (branch && String(branch).trim()) || '';
|
|
|
|
if (!commitStr && !branchStr) return 'Unknown';
|
|
|
|
const shortCommit = commitStr.length >= 7 ? commitStr.substring(0, 7) : commitStr;
|
|
|
|
if (branchStr && shortCommit) {
|
|
return `${branchStr} · ${shortCommit}`;
|
|
}
|
|
if (branchStr) {
|
|
return branchStr;
|
|
}
|
|
if (shortCommit) {
|
|
return shortCommit;
|
|
}
|
|
return 'Unknown';
|
|
},
|
|
|
|
formatDateInfo(dateString) {
|
|
// Handle null, undefined, or empty string
|
|
if (!dateString || !String(dateString).trim()) return 'Unknown';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
// Check if date is valid
|
|
if (isNaN(date.getTime())) {
|
|
return 'Unknown';
|
|
}
|
|
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays < 1) {
|
|
return 'Today';
|
|
} else if (diffDays < 2) {
|
|
return 'Yesterday';
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} days ago`;
|
|
} else if (diffDays < 30) {
|
|
const weeks = Math.floor(diffDays / 7);
|
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
|
} else {
|
|
// Return formatted date for older items
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
} catch (e) {
|
|
console.error('Error formatting date:', e, dateString);
|
|
return 'Unknown';
|
|
}
|
|
}
|
|
};
|
|
|
|
// Update window.app to return full implementation
|
|
window.app = function() {
|
|
return fullImplementation;
|
|
};
|
|
|
|
// If Alpine is already initialized, update the existing component immediately
|
|
if (window.Alpine) {
|
|
// Use requestAnimationFrame for immediate execution without blocking
|
|
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 appElement = document.querySelector('[x-data]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
const existingComponent = appElement._x_dataStack[0];
|
|
// 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;
|
|
}
|
|
|
|
// Make app() available globally
|
|
window.app = app;
|
|
|
|
|
|
// ===== Nested Section Toggle =====
|
|
window.toggleNestedSection = function(sectionId, event) {
|
|
// Prevent event bubbling if event is provided
|
|
if (event) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
}
|
|
|
|
const content = document.getElementById(sectionId);
|
|
const icon = document.getElementById(sectionId + '-icon');
|
|
|
|
if (!content || !icon) {
|
|
console.warn('[toggleNestedSection] Content or icon not found for:', sectionId);
|
|
return;
|
|
}
|
|
|
|
// Check if content is currently collapsed (has 'collapsed' class or display:none)
|
|
const isCollapsed = content.classList.contains('collapsed') ||
|
|
content.style.display === 'none' ||
|
|
(content.style.display === '' && !content.classList.contains('expanded'));
|
|
|
|
if (isCollapsed) {
|
|
// Expand the section
|
|
content.classList.remove('collapsed');
|
|
content.classList.add('expanded');
|
|
content.style.display = 'block';
|
|
content.style.overflow = 'hidden'; // Prevent content jumping during animation
|
|
|
|
// CRITICAL FIX: Use setTimeout to ensure browser has time to layout the element
|
|
// When element goes from display:none to display:block, scrollHeight might be 0
|
|
// We need to wait for the browser to calculate the layout
|
|
setTimeout(() => {
|
|
// Force reflow to ensure transition works
|
|
void content.offsetHeight;
|
|
|
|
// Now measure the actual content height after layout
|
|
const scrollHeight = content.scrollHeight;
|
|
if (scrollHeight > 0) {
|
|
content.style.maxHeight = scrollHeight + 'px';
|
|
} else {
|
|
// Fallback: if scrollHeight is still 0, try measuring again after a brief delay
|
|
setTimeout(() => {
|
|
const retryHeight = content.scrollHeight;
|
|
content.style.maxHeight = retryHeight > 0 ? retryHeight + 'px' : '500px';
|
|
}, 10);
|
|
}
|
|
}, 10);
|
|
|
|
icon.classList.remove('fa-chevron-right');
|
|
icon.classList.add('fa-chevron-down');
|
|
|
|
// After animation completes, remove max-height constraint to allow natural expansion
|
|
setTimeout(() => {
|
|
if (content.classList.contains('expanded') && !content.classList.contains('collapsed')) {
|
|
content.style.maxHeight = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
}, 320); // Slightly longer than transition duration
|
|
} else {
|
|
// Collapse the section
|
|
content.classList.add('collapsed');
|
|
content.classList.remove('expanded');
|
|
content.style.overflow = 'hidden'; // Prevent content jumping during animation
|
|
|
|
// Set max-height to current scroll height first (required for smooth animation)
|
|
const currentHeight = content.scrollHeight;
|
|
content.style.maxHeight = currentHeight + 'px';
|
|
|
|
// Force reflow to apply the height
|
|
void content.offsetHeight;
|
|
|
|
// Then animate to 0
|
|
setTimeout(() => {
|
|
content.style.maxHeight = '0';
|
|
}, 10);
|
|
|
|
// Hide after transition completes
|
|
setTimeout(() => {
|
|
if (content.classList.contains('collapsed')) {
|
|
content.style.display = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
}, 320); // Match the CSS transition duration + small buffer
|
|
|
|
icon.classList.remove('fa-chevron-down');
|
|
icon.classList.add('fa-chevron-right');
|
|
}
|
|
};
|
|
|
|
// ===== Display Preview Functions (from v2) =====
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// ===== Plugin Management Functions =====
|
|
|
|
// Make togglePluginFromTab global so Alpine.js can access it
|
|
window.togglePluginFromTab = async function(pluginId, enabled) {
|
|
try {
|
|
const response = await fetch('/api/v3/plugins/toggle', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, enabled })
|
|
});
|
|
const data = await response.json();
|
|
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
// Update the plugin in window.installedPlugins
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = enabled;
|
|
}
|
|
}
|
|
|
|
// Refresh the plugin list to ensure both management page and config page stay in sync
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
}
|
|
} else {
|
|
// Revert the toggle if API call failed
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
showNotification('Error toggling plugin: ' + error.message, 'error');
|
|
// Revert on error
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to get schema property type for a field path
|
|
function getSchemaPropertyType(schema, path) {
|
|
if (!schema || !schema.properties) return null;
|
|
|
|
const parts = path.split('.');
|
|
let current = schema.properties;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (current && current[part]) {
|
|
if (i === parts.length - 1) {
|
|
return current[part];
|
|
} else if (current[part].properties) {
|
|
current = current[part].properties;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Helper function to escape CSS selector special characters
|
|
function escapeCssSelector(str) {
|
|
if (typeof str !== 'string') {
|
|
str = String(str);
|
|
}
|
|
// Use CSS.escape() when available (handles unicode, leading digits, and edge cases)
|
|
if (typeof CSS !== 'undefined' && CSS.escape) {
|
|
return CSS.escape(str);
|
|
}
|
|
// Fallback to regex-based escaping for older browsers
|
|
// First, handle leading digits and whitespace (must be done before regex)
|
|
let escaped = str;
|
|
let hasLeadingHexEscape = false;
|
|
if (escaped.length > 0) {
|
|
const firstChar = escaped[0];
|
|
const firstCode = firstChar.charCodeAt(0);
|
|
|
|
// Escape leading digit (0-9: U+0030-U+0039)
|
|
if (firstCode >= 0x30 && firstCode <= 0x39) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
// Escape leading whitespace (space: U+0020, tab: U+0009, etc.)
|
|
else if (/\s/.test(firstChar)) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
}
|
|
|
|
// Escape special characters
|
|
escaped = escaped.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
|
|
|
// Escape internal spaces (replace spaces with \ ), but preserve space in hex escape
|
|
if (hasLeadingHexEscape) {
|
|
// Skip the first 6 characters (e.g., "\0030 ") when replacing spaces
|
|
escaped = escaped.slice(0, 6) + escaped.slice(6).replace(/ /g, '\\ ');
|
|
} else {
|
|
escaped = escaped.replace(/ /g, '\\ ');
|
|
}
|
|
|
|
return escaped;
|
|
}
|
|
|
|
async function savePluginConfig(pluginId) {
|
|
try {
|
|
debugLog('Saving config for plugin:', pluginId);
|
|
|
|
// Load schema for type detection
|
|
let schema = {};
|
|
try {
|
|
const schemaResponse = await fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`);
|
|
const schemaData = await schemaResponse.json();
|
|
if (schemaData.status === 'success' && schemaData.data.schema) {
|
|
schema = schemaData.data.schema;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not load schema for type detection:', e);
|
|
}
|
|
|
|
// Find the form in the active plugin tab
|
|
// Alpine.js hides/shows elements with display:none, so we look for the currently visible one
|
|
const allForms = document.querySelectorAll('form[x-on\\:submit\\.prevent]');
|
|
debugLog('Found forms:', allForms.length);
|
|
|
|
let form = null;
|
|
for (const f of allForms) {
|
|
const parent = f.closest('[x-show]');
|
|
if (parent && parent.style.display !== 'none' && parent.offsetParent !== null) {
|
|
form = f;
|
|
debugLog('Found visible form');
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!form) {
|
|
throw new Error('Form not found for plugin ' + pluginId);
|
|
}
|
|
|
|
const formData = new FormData(form);
|
|
const flatConfig = {};
|
|
|
|
// First, collect all checkbox states (including unchecked ones)
|
|
// Unchecked checkboxes don't appear in FormData, so we need to iterate form elements
|
|
for (let i = 0; i < form.elements.length; i++) {
|
|
const element = form.elements[i];
|
|
const name = element.name;
|
|
|
|
// Skip elements without names or submit buttons
|
|
if (!name || element.type === 'submit' || element.type === 'button') {
|
|
continue;
|
|
}
|
|
|
|
// Handle checkboxes explicitly (both checked and unchecked)
|
|
if (element.type === 'checkbox') {
|
|
flatConfig[name] = element.checked;
|
|
}
|
|
// Handle radio buttons
|
|
else if (element.type === 'radio') {
|
|
if (element.checked) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
// Handle select elements (including multi-select)
|
|
else if (element.tagName === 'SELECT') {
|
|
if (element.multiple) {
|
|
// Multi-select: get all selected options
|
|
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
|
|
flatConfig[name] = selectedValues;
|
|
} else {
|
|
// Single select: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
// Handle textarea
|
|
else if (element.tagName === 'TEXTAREA') {
|
|
// Textarea: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now process FormData for other field types
|
|
for (const [key, value] of formData.entries()) {
|
|
// Skip checkboxes - we already handled them above
|
|
// Use querySelector to reliably find element by name (handles dot notation)
|
|
const escapedKey = escapeCssSelector(key);
|
|
const element = form.querySelector(`[name="${escapedKey}"]`);
|
|
if (element && element.type === 'checkbox') {
|
|
continue; // Already processed
|
|
}
|
|
// Skip multi-select - we already handled them above
|
|
if (element && element.tagName === 'SELECT' && element.multiple) {
|
|
continue; // Already processed
|
|
}
|
|
|
|
// Get schema property type if available
|
|
const propSchema = getSchemaPropertyType(schema, key);
|
|
const propType = propSchema ? propSchema.type : null;
|
|
|
|
// Handle based on schema type or field name patterns
|
|
if (propType === 'array') {
|
|
// Check if this is a file upload widget (JSON array in hidden input)
|
|
if (propSchema && propSchema['x-widget'] === 'file-upload') {
|
|
try {
|
|
// Unescape HTML entities that were escaped when setting the value
|
|
let unescapedValue = value;
|
|
if (typeof value === 'string') {
|
|
// Reverse the HTML escaping: " -> ", ' -> ', & -> &
|
|
unescapedValue = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/g, '&');
|
|
}
|
|
|
|
try {
|
|
const jsonValue = JSON.parse(unescapedValue);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
debugLog(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`);
|
|
} else {
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value);
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
// Not valid JSON, use empty array or try comma-separated
|
|
if (value && value.trim()) {
|
|
const arrayValue = value.split(',').map(v => v.trim()).filter(v => v);
|
|
flatConfig[key] = arrayValue;
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
}
|
|
} else {
|
|
// Regular array: convert comma-separated string to array
|
|
const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[key] = arrayValue;
|
|
}
|
|
} else if (propType === 'integer') {
|
|
const numValue = parseInt(value, 10);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
} else if (propType === 'number') {
|
|
const numValue = parseFloat(value);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
} else if (propType === 'boolean') {
|
|
// Boolean from FormData (shouldn't happen for checkboxes, but handle it)
|
|
flatConfig[key] = value === 'on' || value === 'true' || value === true;
|
|
} else {
|
|
// String or other types
|
|
// Check if it's a number field by name pattern (fallback if no schema)
|
|
if (!propType && (key.includes('duration') || key.includes('interval') ||
|
|
key.includes('timeout') || key.includes('teams') || key.includes('fps') ||
|
|
key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) {
|
|
const numValue = parseFloat(value);
|
|
if (!isNaN(numValue)) {
|
|
flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue;
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unchecked checkboxes using schema (if available)
|
|
if (schema && schema.properties) {
|
|
const collectBooleanFields = (props, prefix = '') => {
|
|
const boolFields = [];
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
if (prop.type === 'boolean') {
|
|
boolFields.push(fullKey);
|
|
} else if (prop.type === 'object' && prop.properties) {
|
|
boolFields.push(...collectBooleanFields(prop.properties, fullKey));
|
|
}
|
|
}
|
|
return boolFields;
|
|
};
|
|
|
|
const allBoolFields = collectBooleanFields(schema.properties);
|
|
allBoolFields.forEach(key => {
|
|
if (!(key in flatConfig)) {
|
|
flatConfig[key] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert dot notation to nested object
|
|
const dotToNested = (obj) => {
|
|
const result = {};
|
|
for (const key in obj) {
|
|
const parts = key.split('.');
|
|
let current = result;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
if (!current[parts[i]]) {
|
|
current[parts[i]] = {};
|
|
}
|
|
current = current[parts[i]];
|
|
}
|
|
current[parts[parts.length - 1]] = obj[key];
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const config = dotToNested(flatConfig);
|
|
|
|
debugLog('Saving config for', pluginId, ':', config);
|
|
debugLog('Flat config before nesting:', flatConfig);
|
|
|
|
// Save to backend
|
|
const response = await fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, config })
|
|
});
|
|
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch (e) {
|
|
throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
if (!response.ok || data.status !== 'success') {
|
|
let errorMessage = data.message || 'Failed to save configuration';
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
|
|
}
|
|
throw new Error(errorMessage);
|
|
} else {
|
|
showNotification(`Configuration saved for ${pluginId}`, 'success');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error saving plugin configuration:', error);
|
|
showNotification('Error saving plugin configuration: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Notification helper function
|
|
// Fix invalid number inputs before form submission
|
|
// This prevents "invalid form control is not focusable" errors
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// showNotification is provided by notification.js widget
|
|
// This fallback is only used if the widget hasn't loaded yet
|
|
if (typeof window.showNotification !== 'function') {
|
|
window.showNotification = function(message, type = 'info') {
|
|
debugLog(`[${type.toUpperCase()}]`, message);
|
|
const notification = document.createElement('div');
|
|
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
|
|
type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'
|
|
} text-white`;
|
|
notification.textContent = message;
|
|
document.body.appendChild(notification);
|
|
setTimeout(() => { notification.style.opacity = '0'; setTimeout(() => notification.remove(), 500); }, 3000);
|
|
};
|
|
}
|
|
|
|
// Section toggle function - already defined earlier, but ensure it's not overwritten
|
|
// (duplicate definition removed - function is defined in early script block above)
|
|
|
|
// Plugin config handler functions (idempotent initialization)
|
|
if (!window.__pluginConfigHandlersInitialized) {
|
|
window.__pluginConfigHandlersInitialized = true;
|
|
|
|
// Initialize state on window object
|
|
window.pluginConfigRefreshInProgress = window.pluginConfigRefreshInProgress || new Set();
|
|
|
|
// Validate plugin config form and show helpful error messages
|
|
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}`;
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(errorMessage, 'error');
|
|
} else {
|
|
alert(errorMessage); // Fallback if showNotification not available
|
|
}
|
|
|
|
// 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
|
|
};
|
|
|
|
// Handle config save response with detailed error logging
|
|
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) {
|
|
// Try to get message from response JSON
|
|
let message = 'Configuration saved successfully!';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
message = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const responseData = JSON.parse(xhr.responseText);
|
|
message = responseData.message || message;
|
|
}
|
|
} catch (e) {
|
|
// Use default message if parsing fails
|
|
}
|
|
showNotification(message, 'success');
|
|
} else {
|
|
// Request failed - log detailed error information
|
|
console.error('Config save failed:', {
|
|
status: status,
|
|
statusText: xhr?.statusText,
|
|
responseText: xhr?.responseText
|
|
});
|
|
|
|
// Try to parse error response
|
|
let errorMessage = 'Failed to save configuration';
|
|
try {
|
|
if (xhr?.responseJSON) {
|
|
const errorData = xhr.responseJSON;
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
if (errorData.validation_errors) {
|
|
errorMessage += ': ' + errorData.validation_errors.join(', ');
|
|
}
|
|
} else if (xhr?.responseText) {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
if (errorData.validation_errors) {
|
|
errorMessage += ': ' + errorData.validation_errors.join(', ');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// If parsing fails, use status text
|
|
errorMessage = xhr?.statusText || errorMessage;
|
|
}
|
|
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Handle toggle response
|
|
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'}`;
|
|
}
|
|
|
|
// Try to get message from response
|
|
let message = 'Plugin status updated';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
message = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const responseData = JSON.parse(xhr.responseText);
|
|
message = responseData.message || message;
|
|
}
|
|
} catch (e) {
|
|
// Use default message
|
|
}
|
|
showNotification(message, 'success');
|
|
} else {
|
|
// Revert checkbox state on error
|
|
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
|
|
if (checkbox) {
|
|
checkbox.checked = !checkbox.checked;
|
|
}
|
|
|
|
// Try to get error message from response
|
|
let errorMessage = 'Failed to update plugin status';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
errorMessage = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
}
|
|
} catch (e) {
|
|
// Use default message
|
|
}
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Handle plugin update response
|
|
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');
|
|
}
|
|
};
|
|
|
|
// Refresh plugin config (with duplicate prevention)
|
|
window.refreshPluginConfig = function(pluginId) {
|
|
// Prevent concurrent refreshes
|
|
if (window.pluginConfigRefreshInProgress.has(pluginId)) {
|
|
return;
|
|
}
|
|
|
|
const container = document.getElementById(`plugin-config-${pluginId}`);
|
|
if (container && window.htmx) {
|
|
window.pluginConfigRefreshInProgress.add(pluginId);
|
|
|
|
// Clear container first, then reload
|
|
container.innerHTML = '';
|
|
window.htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
|
|
target: container,
|
|
swap: 'innerHTML'
|
|
});
|
|
|
|
// Clear flag after delay
|
|
setTimeout(() => {
|
|
window.pluginConfigRefreshInProgress.delete(pluginId);
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
// Plugin action handlers
|
|
window.runPluginOnDemand = function(pluginId) {
|
|
if (typeof window.openOnDemandModal === 'function') {
|
|
window.openOnDemandModal(pluginId);
|
|
} else {
|
|
showNotification('On-demand modal not available', 'error');
|
|
}
|
|
};
|
|
|
|
window.stopOnDemand = function() {
|
|
if (typeof window.requestOnDemandStop === 'function') {
|
|
window.requestOnDemandStop({});
|
|
} else {
|
|
showNotification('Stop function not available', 'error');
|
|
}
|
|
};
|
|
|
|
window.executePluginAction = function(actionId, actionIndex, pluginId) {
|
|
fetch(`/api/v3/plugins/action?plugin_id=${pluginId}&action_id=${actionId}`, {
|
|
method: 'POST'
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showNotification(data.message || 'Action executed', 'success');
|
|
} else {
|
|
showNotification(data.message || 'Action failed', 'error');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
showNotification('Failed to execute action', 'error');
|
|
});
|
|
};
|
|
}
|
|
|
|
function getAppComponent() {
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
return appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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 = getAppComponent();
|
|
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
|
|
await appComponent.loadInstalledPlugins();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error updating plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function updateAllPlugins() {
|
|
try {
|
|
const plugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
|
|
|
|
if (!plugins.length) {
|
|
showNotification('No installed plugins to update.', 'warning');
|
|
return;
|
|
}
|
|
|
|
showNotification(`Checking ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} for updates...`, 'info');
|
|
|
|
let successCount = 0;
|
|
let failureCount = 0;
|
|
|
|
for (const plugin of plugins) {
|
|
const pluginId = plugin.id;
|
|
const pluginName = plugin.name || pluginId;
|
|
|
|
try {
|
|
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();
|
|
const status = data.status || 'info';
|
|
const message = data.message || `Checked ${pluginName}`;
|
|
|
|
showNotification(message, status);
|
|
|
|
if (status === 'success') {
|
|
successCount += 1;
|
|
} else {
|
|
failureCount += 1;
|
|
}
|
|
} catch (error) {
|
|
failureCount += 1;
|
|
showNotification(`Error updating ${pluginName}: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
const appComponent = getAppComponent();
|
|
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
|
|
await appComponent.loadInstalledPlugins();
|
|
}
|
|
|
|
if (failureCount === 0) {
|
|
showNotification(`Finished checking ${successCount} plugin${successCount === 1 ? '' : 's'} for updates.`, 'success');
|
|
} else {
|
|
showNotification(`Updated ${successCount} plugin${successCount === 1 ? '' : 's'} with ${failureCount} failure${failureCount === 1 ? '' : 's'}. Check logs for details.`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Bulk plugin update failed:', error);
|
|
showNotification('Failed to update all plugins: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
window.updateAllPlugins = updateAllPlugins;
|
|
|
|
|
|
async function uninstallPlugin(pluginId) {
|
|
try {
|
|
// Get plugin info from window.installedPlugins
|
|
const plugin = window.installedPlugins ? window.installedPlugins.find(p => p.id === pluginId) : null;
|
|
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
|
|
|
if (!confirm(`Are you sure you want to uninstall ${pluginName}?`)) {
|
|
return;
|
|
}
|
|
|
|
showNotification(`Uninstalling ${pluginName}...`, 'info');
|
|
|
|
const response = await fetch('/api/v3/plugins/uninstall', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
// Check if operation was queued
|
|
if (data.status === 'success' && data.data && data.data.operation_id) {
|
|
// Operation was queued, poll for completion
|
|
const operationId = data.data.operation_id;
|
|
showNotification(`Uninstall queued for ${pluginName}...`, 'info');
|
|
await pollUninstallOperation(operationId, pluginId, pluginName);
|
|
} else if (data.status === 'success') {
|
|
// Direct uninstall completed immediately
|
|
showNotification(data.message || `Plugin ${pluginName} uninstalled successfully`, 'success');
|
|
// Refresh the plugin list
|
|
await app.loadInstalledPlugins();
|
|
} else {
|
|
// Error response
|
|
showNotification(data.message || 'Failed to uninstall plugin', data.status || 'error');
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error uninstalling plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts = 60, attempt = 0) {
|
|
if (attempt >= maxAttempts) {
|
|
showNotification(`Uninstall operation timed out for ${pluginName}`, 'error');
|
|
// Refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v3/plugins/operation/${operationId}`);
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success' && data.data) {
|
|
const operation = data.data;
|
|
const status = operation.status;
|
|
|
|
if (status === 'completed') {
|
|
// Operation completed successfully
|
|
showNotification(`Plugin ${pluginName} uninstalled successfully`, 'success');
|
|
await app.loadInstalledPlugins();
|
|
} else if (status === 'failed') {
|
|
// Operation failed
|
|
const errorMsg = operation.error || operation.message || `Failed to uninstall ${pluginName}`;
|
|
showNotification(errorMsg, 'error');
|
|
// Refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
} else if (status === 'pending' || status === 'in_progress') {
|
|
// Still in progress, poll again
|
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
} else {
|
|
// Unknown status, poll again
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}
|
|
} else {
|
|
// Error getting operation status, try again
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error polling operation status:', error);
|
|
// On error, refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
}
|
|
}
|
|
|
|
// Assign to window for global access
|
|
window.uninstallPlugin = uninstallPlugin;
|
|
|
|
async function refreshPlugin(pluginId) {
|
|
try {
|
|
// Switch to the plugin manager tab briefly to refresh
|
|
const originalTab = app.activeTab;
|
|
app.activeTab = 'plugins';
|
|
|
|
// Wait a moment then switch back
|
|
setTimeout(() => {
|
|
app.activeTab = originalTab;
|
|
app.showNotification(`Refreshed ${pluginId}`, 'success');
|
|
}, 100);
|
|
|
|
} catch (error) {
|
|
app.showNotification('Error refreshing plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Format commit information for display
|
|
function formatCommitInfo(commit, branch) {
|
|
if (!commit && !branch) return 'Unknown';
|
|
const shortCommit = commit ? String(commit).substring(0, 7) : '';
|
|
const branchText = branch ? String(branch) : '';
|
|
|
|
if (branchText && shortCommit) {
|
|
return `${branchText} · ${shortCommit}`;
|
|
}
|
|
if (branchText) {
|
|
return branchText;
|
|
}
|
|
if (shortCommit) {
|
|
return shortCommit;
|
|
}
|
|
return 'Latest';
|
|
}
|
|
|
|
// Format date for display
|
|
function formatDateInfo(dateString) {
|
|
if (!dateString) return 'Unknown';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays < 1) {
|
|
return 'Today';
|
|
} else if (diffDays < 2) {
|
|
return 'Yesterday';
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} days ago`;
|
|
} else if (diffDays < 30) {
|
|
const weeks = Math.floor(diffDays / 7);
|
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
|
} else {
|
|
// Return formatted date for older items
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
} catch (e) {
|
|
return dateString;
|
|
}
|
|
}
|
|
|
|
// Make functions available to Alpine.js
|
|
window.formatCommitInfo = formatCommitInfo;
|
|
window.formatDateInfo = formatDateInfo;
|
|
|