13 Commits

Author SHA1 Message Date
Chuck
098a738891 fix(plugin-loader): fail-fast when install_dependencies returns False
Previously the boolean result was silently discarded, so a failed pip
install would log a warning but continue attempting to import the plugin
module — resulting in a confusing ModuleNotFoundError instead of a clear
dependency failure message.

Now raises PluginError with plugin_id and plugin_dir if dependency
installation fails, stopping the load before the import is attempted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:47:33 -04:00
Chuck
abade43772 fix(plugin-loader): use realpath+startswith containment check for CodeQL path-injection
Replace relative_to() (not recognised by CodeQL as a path sanitiser) with
the os.path.realpath() + startswith() pattern that CodeQL explicitly models
as sanitising py/path-injection.

- Add plugins_dir optional param to install_dependencies() and load_plugin()
- PluginManager.load_plugin() passes self.plugins_dir as the trusted anchor;
  install_dependencies() validates that the resolved plugin_dir starts with
  the resolved plugins_dir before any file I/O
- Replace all Path.read_bytes/read_text/write_text/exists with open() and
  os.path.isfile() so the sanitised string paths flow directly to file ops
  without re-introducing taint through Path object conversion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:32:03 -04:00
Chuck
b44ff079c9 fix(plugin-loader): address CodeQL path expression and I/O error handling
- Add explicit relative_to() containment check after path resolution so
  CodeQL recognizes the plugin directory boundary (fixes 4 CodeQL alerts:
  Uncontrolled data used in path expression, lines 168/172/189/205)
- Wrap requirements_file.read_bytes() in try/except OSError — on Raspberry
  Pi with flaky SD card storage this can fail; returns False with a clear log
- Wrap marker_path.read_text() in try/except OSError — a corrupted marker
  falls through to a clean reinstall instead of crashing
- Wrap both marker_path.write_text() calls in try/except OSError — pip
  already succeeded at this point so a marker write failure should not
  return False or propagate through the generic exception handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:42:03 -04:00
Chuck
6c4700583b fix(plugin-loader): detect new deps via requirements.txt hash instead of empty marker
The .dependencies_installed marker was an empty file, so adding a new
package to requirements.txt (e.g. astral in ledmatrix-weather v2.3.0)
never triggered a pip re-install on existing installs — the file existed
so the check returned early.

The marker now stores a SHA-256 hash of requirements.txt. On every plugin
load, the loader compares the current hash to the stored one; a mismatch
(or missing marker) triggers pip install and writes the new hash.
store_manager._install_dependencies() also writes the hash marker after a
store install/update so the loader skips a redundant pip run on next boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:38:19 -04:00
Chuck
f96fdd9f24 fix(plugins): skip update for local-only plugins instead of failing (#354)
Adds a local_only flag to the starlark-apps manifest so the update
endpoint returns a skipped status rather than recording a false failure
when the plugin has no git repo and no registry entry.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:42:25 -04:00
sarjent
35c540d0e0 fix(reconciler): prefer config.json over state manager for enabled mismatch (#353)
* fix(reconciler): prefer config.json over state manager for enabled mismatch

When the enabled state in config.json and plugin_state.json diverged, the
reconciler was syncing config.json to match plugin_state.json (state manager
wins). This silently disabled plugins on every restart whenever the state
file had an outdated enabled=false entry — most commonly after an
uninstall+reinstall cycle, where the reinstall left the plugin in the
installed-but-not-enabled state while config.json still had enabled=true.

Flip the sync direction: update plugin_state.json via set_plugin_enabled()
to match config.json instead. config.json is the user-editable source of
truth; the state file is an internal tracker that should follow it when they
disagree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(reconciler): return set_plugin_enabled result instead of always True

Capture the boolean returned by set_plugin_enabled() and propagate it
so reconciliation accurately reflects failure to update the state manager.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:55:30 -04:00
Chuck
7603909c59 feat(ui): add reusable json-file-manager widget (#352)
* feat(ui): add reusable json-file-manager widget for plugin file management

Introduces JsonFileManager — a zero-CDN, keyboard-accessible, configurable
widget for managing JSON data files from plugin configuration forms.

web_interface/static/v3/js/widgets/json-file-manager.js (new):
- Self-contained class with scoped CSS (no global leakage)
- File list with cards: enable/disable toggle, entry count, size, date
- Drag-and-drop + click-to-browse JSON upload
- Textarea-based JSON editor (no CDN); Format + Validate buttons
- Ctrl+S to save, Escape to close any open modal
- Create-new-file modal with configurable fields and validation
- Delete confirmation modal
- All actions (list/get/save/upload/delete/create/toggle) are configurable
  via x-widget-config in config_schema.json — no plugin-ID hardcoding

web_interface/static/v3/plugins_manager.js:
- New handler for x-widget: "json-file-manager" — renders mount div,
  instantiates JsonFileManager with x-widget-config and plugin ID

web_interface/templates/v3/base.html:
- Include json-file-manager.js (defer) before plugins_manager.js

Usage: set x-widget: "json-file-manager" + x-widget-config in any
plugin's config_schema.json (see ledmatrix-plugins of-the-day for a
complete example).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(json-file-manager): review fixes — type=button, finally, display_name, instance tracking

- Add type="button" to every button in the template (replace_all) so none
  default to submit inside the plugin-config-form
- Wrap _doSave/_doDelete/_doCreate fetch blocks in try/finally so _idle()
  always fires, not only on the error path
- _doCreate validation: skip the required-check for display_name (f.key
  !== 'display_name') and only validate pattern when val is non-empty, so
  the auto-derive logic at the end of the loop can run; simplify the
  derive block to a single conditional instead of nested DOM lookups
- plugins_manager.js: track instances in window.__jfmInstances[safeFieldId]
  and call _destroy() on any previous instance before mounting a new one,
  preventing duplicate keydown handlers when the config form is re-rendered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(json-file-manager): use validity.patternMismatch; destroy all instances on remount

- Replace `new RegExp(f.pattern).test(val)` with `el.validity.patternMismatch`
  to avoid potential SyntaxError from untrusted pattern strings and rely on the
  browser's already-validated pattern attribute instead
- plugins_manager.js: iterate all window.__jfmInstances and call _destroy() on
  every entry before mounting, then reset the map, so no orphaned keydown
  handlers survive when any plugin config form is re-rendered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugins_manager): scope jfm instance teardown to current mount key only

The global sweep (Object.values + window.__jfmInstances = {}) destroyed
sibling file-manager widgets when any one of them was remounted. Replace
with a targeted destroy of window.__jfmInstances[safeFieldId] only,
leaving all other entries untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(json-file-manager): address Codacy security warnings

- Replace Math.random() with crypto.getRandomValues() for UID generation
- Remove unused variable `u` in _card()
- Guard this.actions property access with hasOwnProperty
- Replace btn.innerHTML in _busy/_idle with DOM manipulation + textContent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:46:38 -04:00
Chuck
34b186125a fix(logs): include ledmatrix-web logs in viewer and log subprocess stderr on failure (#350)
Two bugs conspired to produce "check the logs" toasts with an empty log viewer:

1. The log viewer (both SSE stream and REST endpoint) only queried
   ledmatrix.service via journalctl. Web API errors are logged by the
   Flask process running as ledmatrix-web.service, so they never
   appeared in the viewer. Add -u ledmatrix-web.service to both calls;
   also add --output=short-iso so timestamps from the two services
   sort cleanly when interleaved. Use shutil.which-resolved absolute
   paths for sudo/journalctl (S607 compliance) in api_v3.py; fall back
   to known Pi paths if which returns None.

2. app.py: resolve journalctl and systemctl to absolute paths via
   shutil.which at module init (_JOURNALCTL, _SYSTEMCTL). Replace bare
   names in logs_generator() and the cached systemctl is-active check.
   Guard both sites: logs_generator yields a clear SSE error message
   and sleeps 60 s if journalctl is not found; the systemctl block is
   skipped entirely if systemctl is not found, leaving the cache at its
   last-known value.

3. When execute_system_action() ran a systemctl command that returned
   non-zero, only the return code was logged — result.stderr was
   silently discarded. Log it at ERROR level and include returncode and
   stderr in the JSON response so callers get actionable failure details.
   Same fix applied to the early-return start_display branch.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 09:55:59 -04:00
Chuck
ea95f37d73 fix(reconciler): add sync, github, youtube to _SYSTEM_CONFIG_KEYS (#351)
config_manager.load_config() deep-merges config_secrets.json into the
main config before returning it. This means secrets top-level keys
(github, youtube) appear alongside structural config keys (sync) in the
dict that _get_config_state() iterates.

_SYSTEM_CONFIG_KEYS was missing all three, so the reconciler treated them
as plugin IDs and flagged them as PLUGIN_MISSING_ON_DISK on every startup,
showing the "Stale plugin config entries found" warning banner to users on
a fresh install where those plugins have never existed.

Add the three keys with brief comments explaining which file each comes
from so the distinction is clear when the list grows.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:09:26 -04:00
Chuck
0c7d03a476 fix(web-ui): support multiple browser tabs via SSE broadcaster (#349)
* fix(web-ui): support multiple browser tabs via SSE broadcaster pattern

Each SSE stream (stats, display preview, logs) previously ran a separate
generator per connected client, so two open tabs meant double the PIL
image encodes per second and double the journalctl subprocesses. Under
load or on reconnect storms the tight "20 per minute" rate limit was
easily exhausted, silently breaking tabs without any user-facing
explanation.

- Replace per-client sse_response generators with _StreamBroadcaster:
  one background thread per stream type fans data to all subscribed
  client queues, keeping CPU/subprocess work constant regardless of
  how many tabs are open
- Add 30-second SSE heartbeat comments to keep idle connections alive
  through proxies
- Raise SSE rate limit from "20/min" to "200/min" to prevent reconnect
  storms from exhausting the limit
- Assign statsSource/displaySource to window.* so reconnectSSE() in
  app.js can actually reach them (was dead code due to const scoping)
- Add displaySource error handler so display preview failures are no
  longer completely silent
- Improve connection status badge: shows "Reconnecting…" on first few
  errors, "Disconnected" with tooltip hint after persistent failure
- Complete the empty displaySource.onmessage stub in reconnectSSE()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): harden SSE broadcaster — drop-oldest on full queue, exit on no subscribers, reattach reconnect handlers

- _broadcast: on queue.Full drop the oldest item and retry the put
  instead of removing the client from _clients — a slow tab now stays
  subscribed and receives the latest data rather than being silently
  ejected
- _broadcast: break instead of continue when _clients is empty so the
  background generator thread exits rather than spinning indefinitely;
  subscribe() already restarts it on the next connection
- base.html: expose _statsOpenHandler, _statsErrorHandler, and
  _displayErrorHandler as window properties so reconnectSSE() can
  reattach them after replacing the EventSource instances
- app.js: reconnectSSE() now reattaches those handlers after creating
  each new EventSource so the status badge and display-stream console
  logging survive a manual reconnect

Heartbeat path (~line 646) is a queue read (q.get), not a write; no
queue.Full can occur there so no change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): declare updateDisplayPreview in ESLint global comment

Codacy flagged 'updateDisplayPreview is not defined' at app.js:73.
The function is defined in base.html and already guarded with
typeof check, matching the existing updateSystemStats pattern — it
just wasn't listed in the /* global */ declaration at the top of the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:37:03 -04:00
Chuck
321a87f734 fix(wifi): fix AP mode, captive portal, and WiFi connect flow (#348)
* fix(wifi): fix AP mode, captive portal, and WiFi connect flow

- Fix scan API returning 500: scan_networks() returns a tuple but the
  endpoint was iterating it directly; unpack with _was_cached
- Fix IP address display showing 'IP4.ADDRESS[1]:x.x.x.x': nmcli -t
  output includes the field label; split on ':' before '/'
- Add force parameter to enable_ap_mode() to bypass WiFi/Ethernet
  guards; expose via force JSON body field in the AP enable endpoint
- Fix daemon auto-disabling forced AP: add _FORCE_AP_FLAG_PATH flag
  file written on force-enable and checked in check_and_manage_ap_mode
  before auto-disabling; disable_ap_mode() clears it
- Fix wifi_connected false positive in AP mode: _get_status_nmcli()
  was reporting wlan0 as 'connected' when it was running as AP;
  override wifi_connected=False when _is_ap_mode_active() is True
- Fix AP verification failure on async NM activation: retry
  _get_ap_status_nmcli() up to 5 times with 2s delay instead of
  single immediate check
- Fix WiFi connect ignoring existing NM connections: nmcli does not
  support 802-11-wireless.ssid as a column in 'connection show';
  replace with NAME,TYPE list then per-connection SSID query via -g
  (fixes 'netplan generate failed' error on Trixie / netplan systems)
- Fix failsafe AP re-enable blocked by Ethernet: all recovery-path
  enable_ap_mode() calls in connect_to_network() now pass force=True

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): strict bool parsing for force; nosec annotation parity

- api_v3.py: replace bool(...) coercion for force with strict check —
  only actual boolean True or strings "true"/"1" (case-insensitive)
  pass; "false", integers, and other strings are treated as False so
  the Ethernet/WiFi guards and _FORCE_AP_FLAG_PATH cannot be bypassed
  by accident
- wifi_manager.py: add nosec B108 annotation to _IP_FORWARD_SAVE_PATH
  to match the identical annotation already on _FORCE_AP_FLAG_PATH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): suppress false-positive Bandit B603/B607 on new nmcli calls

Both subprocess.run calls in the SSID connection lookup use fixed
arguments (no user input) or values derived from nmcli's own output —
not from user-controlled data. Add nosec B603 B607 annotations to
silence the Codacy/Bandit warnings, consistent with existing nosec
usage in the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): address four review findings in wifi_manager.py

IP parsing (line 476): use partition(':') so bare "ip/mask" lines
(no field-label prefix) are handled without IndexError; falls back to
the full string when no ':' is present before splitting on '/'.

AP-mode override comment (line 503): add one-line explanation above
the wifi_connected/ssid/ip_address clear so maintainers know why the
fields are reset while wlan0 reports as "connected".

Stale force-flag cleanup (__init__): remove a left-over
_FORCE_AP_FLAG_PATH from a prior crash on first instantiation per
process (guarded by class-level _startup_cleanup_done so the nmcli
AP-state check only runs once, not on every per-request instantiation).

Force-flag logging (enable_ap_mode): log at debug when force=True is
applied, log success at debug and failure with OSError details at
warning for both the hostapd and nmcli hotspot paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Chuck <chuck@example.com>
2026-05-24 16:12:59 -04:00
Chuck
9930bd33b1 test: add 306 new tests covering previously untested modules (#347)
* test: add 306 new tests covering previously untested modules

Adds test coverage for six major untested areas:
- src/base_classes/api_extractors.py — ESPN football, baseball, hockey, soccer extractors
- src/base_classes/data_sources.py — ESPN, MLB, and soccer API data sources (HTTP mocked)
- src/common/game_helper.py — game extraction, filtering, sorting, and summaries
- src/common/utils.py — all utility functions (normalise, format, validate, parse)
- src/common/scroll_helper.py — ScrollHelper init, create, update, visible portion, duration
- src/background_data_service.py — cache hit/miss paths, retry, cancel, cleanup, singleton
- src/vegas_mode/config.py — VegasModeConfig from_config, validate, update, ordering
- src/logo_downloader.py — normalize_abbreviation, filename variations, directory helpers
- src/plugin_system/health_monitor.py — HealthStatus determination, metrics, suggestions, lifecycle

https://claude.ai/code/session_015792DiGo27JbgH5mk3KBjk

* fix(tests): thread cleanup on assertion failure, reduce oversized image

- test_health_monitor.py: wrap start_monitoring calls in try/finally so
  the background thread is always stopped even when an assertion fails
- test_scroll_helper.py: reduce 50,000px test image to 5,000px to avoid
  unnecessary memory pressure on Raspberry Pi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chuck <chuck@example.com>
2026-05-24 09:38:15 -04:00
Chuck
713539e491 fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning (#346)
* fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning

- base.html: add htmx:afterSettle listener to set data-loaded on tab
  containers after HTMX swaps their content, preventing the overview
  partial from being re-fetched (and handlers lost) on every tab switch
- base.html: call htmx.process() in loadOverviewDirect/loadPluginsDirect
  fallbacks so buttons get HTMX handlers even if HTMX finished its
  initial body scan before the fallback fetch completed
- overview.html + index.html (11 buttons): replace event.detail.xhr.responseJSON
  (undefined in HTMX 1.9.x) with JSON.parse(event.detail.xhr.responseText)
  so quick action toast notifications actually fire
- plugins_manager.js: add guarded htmx:afterSettle listener that only calls
  attachInstallButtonHandler when #install-plugin-from-url is in the DOM,
  eliminating the spurious console warning on non-plugin tab loads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): ensure quick-action toasts always fire even on xhr/parse failure

Replace silent catch(e){} in all 11 hx-on:htmx:after-request handlers with a
pattern that sets default message/status before the try block and calls
showNotification(m,s) unconditionally after it, so a fallback toast is shown
whenever xhr is absent or responseText is not valid JSON.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): show error toast on non-JSON 4xx/5xx quick-action responses

In the catch block of all 11 hx-on:htmx:after-request handlers, check
xhr.status >= 400 and downgrade s to 'error' so a failed action that
returns an HTML error page (or other non-JSON body) surfaces as an error
toast instead of the optimistic 'success'/'info' default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): guard setTimeout fallback for attachInstallButtonHandler

The 500ms fallback setTimeout was calling attachInstallButtonHandler()
unconditionally even when the plugins partial wasn't in the DOM, causing
a spurious console.warn on every page load. Add the same element-existence
check already present on the htmx:afterSettle listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix backup API 404s, hardware status 500, and HTMX loading race

- Add all backup API routes to api_v3.py: preview, list, export,
  validate, restore (with plugin reinstall), download, delete
- Fix PermissionError on /hardware/status: return graceful 200 instead
  of 500 when the status file is owned by a different user; also fix
  root cause by writing the file world-readable (0o644) in display_manager
- Fix HTMX race: dispatch htmx:ready window event from HTMX onload
  callback; loadTabContent now waits for that event instead of
  immediately falling back to direct fetch (eliminating the
  "HTMX not available" console warning on initial load)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Cancel HTMX fallback timers when htmx:ready fires

The 5-second setTimeout fallbacks for plugins and overview were firing
before the htmx:ready event arrived, logging spurious warnings. Each
timer now self-cancels via htmx:ready so the fallback only triggers
when HTMX genuinely fails to load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address review feedback: error leaks, ok:false, htmx:ready coverage

- Backup endpoints: replace raw str(e) in user-facing responses with a
  generic message; full exception still logged via exc_info=True
- hardware/status: change ok:null to ok:false for PermissionError and
  json.JSONDecodeError so the UI's hw.ok===false check triggers correctly
- base.html: dispatch htmx:ready from the fallback load path so any
  deferred listeners fire on CDN-fallback loads too
- loadTabContent: also listen for htmx-load-failed so overview/wifi/plugins
  fall back to direct fetch when HTMX is completely unavailable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Treat system-managed pip packages as satisfied for dependency marker

When a plugin's requirements.txt includes a package installed via the
system package manager (dnf/apt), pip fails with 'uninstall-no-record-file'
because it can't replace the system-tracked copy. The package is present
and functional, but the missing marker caused the install to be retried
on every service restart.

Detect this specific error pattern: if the only pip failure is
uninstall-no-record-file, write the .dependencies_installed marker and
log a warning instead of returning False, suppressing the repeated warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix uninstall-no-record-file detection condition

The previous check used a string replacement that left 'error:' in the
remaining text, causing the condition to always evaluate false. Simplify
to a direct substring check: if 'uninstall-no-record-file' appears in pip
stderr the affected package is installed at the system level and we write
the marker, suppressing the repeated warning on every restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Resolve CodeQL security findings in backup API

Path traversal (CWE-22):
- backup_download: switch from send_file(user-tainted-path) to
  send_from_directory(_BACKUP_EXPORT_DIR, filename); Flask uses
  werkzeug safe_join internally which CodeQL recognises as a sanitizer
- backup_delete: enumerate the export directory and match by name so
  entry.unlink() operates on a filesystem-derived Path rather than one
  constructed from user input; _safe_backup_path still guards first

Information exposure through exceptions (CWE-209):
- backup_validate: err_msg from validate_backup() can embed exception
  strings containing temp-file paths; log the detail, return a generic
  'Invalid or corrupted backup file' to the client
- Other backup endpoints: already fixed (str(e) -> generic message);
  CodeQL alerts will clear on next scan

plugin_loader.py:185 (path traversal): false positive — requirements_file
is constructed from plugin_dir returned by find_plugin_directory() (a
filesystem scan), not from raw HTTP request input; no change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix pre-existing information exposure in version and action endpoints

- get_system_version (alert #218): replaced str(e) with generic message;
  exception still logged via logger.error(exc_info=True)
- execute_system_action (alert #216): removed str(e) and full
  traceback.format_exc() from the HTTP response — the full stack trace
  was being sent directly to clients; replaced with generic message and
  proper logger.error call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix remaining GitHub CodeQL security alerts

- py/stack-trace-exposure: Remove str(e) and traceback.format_exc() from
  all HTTP responses across api_v3.py, pages_v3.py, and app.py; replace
  with generic messages and logger.error(exc_info=True)
- py/reflective-xss: Escape partial_name via markupsafe.escape in the
  load_partial 404 response
- py/path-injection: Add regex validation of plugin_id before filesystem
  use in _load_plugin_config_partial
- py/incomplete-url-substring-sanitization: Replace 'github.com' in
  substring checks with urlparse hostname comparison in store_manager.py
- py/clear-text-logging-sensitive-data: Remove football-scoreboard debug
  prints and sensitive request-body prints from update endpoint
- js/bad-tag-filter: Replace script-only regex in BaseWidget.sanitizeValue
  with DOM-based textContent stripping that removes all HTML
- js/incomplete-sanitization: Fix escapeAttr to properly encode &, ", ',
  <, > using HTML entities instead of backslash escaping
- js/prototype-pollution-utility: Add __proto__/constructor/prototype
  key guards to deepMerge function in plugins_manager.js
- app.py error handlers: Always return generic messages; remove debug-mode
  branches that could expose tracebacks in production

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix three remaining CodeQL path-injection and info-exposure alerts

- plugin_loader.py: resolve plugin_dir with strict=True and validate
  marker_path with relative_to() before any filesystem writes, giving
  CodeQL the positive sanitization pattern it requires (py/path-injection)
- api_v3.py _safe_backup_path: replace substring negative checks with a
  strict positive regex (^[a-zA-Z0-9][a-zA-Z0-9._-]{0,200}\.zip$) that
  CodeQL recognises as sanitising the user-supplied filename
  (py/path-injection)
- api_v3.py backup_validate: whitelist known-safe manifest fields before
  returning JSON, preventing any exception strings captured inside
  validate_backup() from reaching the HTTP response (py/stack-trace-exposure)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Resolve 29 open CodeQL security alerts across 5 files

py/flask-debug (#214):
- debug_web_manual.py: read debug mode from LEDMATRIX_FLASK_DEBUG env var
  instead of hardcoded True

py/stack-trace-exposure (#216, #218):
- api_v3.py execute_system_action: remove subprocess stdout/stderr from
  HTTP responses; log via logger instead
- api_v3.py get_git_version: validate output matches safe ref format
  (^[a-zA-Z0-9._-]+$) before including in response
- api_v3.py: remove all remaining traceback.format_exc() dead variables
  and print() debug calls (replaced with logger.debug/warning)

py/reflective-xss (#207, #208, #209, #210, #211, #212):
- api_v3.py: remove plugin_id from all error/success response messages
  (uninstall, install, update, health, not-found responses)
- pages_v3.py load_partial: return static "Partial not found" message
  instead of echoing partial_name
- pages_v3.py _load_starlark_config_partial: add app_id regex validation,
  use static error messages instead of f-strings with app_id

py/path-injection (#187–#206):
- pages_v3.py _load_plugin_config_partial: resolve plugins_base and
  validate _plugin_dir with relative_to() before all file operations;
  same for assets metadata directory
- pages_v3.py _load_starlark_config_partial: resolve starlark_base and
  validate schema_file/config_file paths with relative_to()
- plugin_loader.py _find_plugin_directory: resolve plugins_dir and
  validate strategy-2 candidates with relative_to()
- plugin_loader.py install_dependencies: resolve plugin_dir first, then
  construct requirements_file and marker_path from resolved base
- plugin_loader.py load_module: resolve plugin_dir with strict=True and
  validate entry_file with relative_to() before exec_module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix 15 remaining CodeQL path-injection and stack-trace-exposure alerts

Switch from resolve()+relative_to() to os.path.basename() reassignment,
which CodeQL recognizes as a path sanitizer that breaks the taint chain.
Also remove exception objects from backup_manager validate_backup return
strings to eliminate the stack-trace-exposure taint source.

Fixes alerts #227, #233, #234, #235, #237, #238, #239, #240, #241,
#242, #243, #244, #245, #246, #247.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix broken logger format string and leaked exception in config save error

- pages_v3.py: plain string was used instead of %-style substitution,
  so every manifest-read failure logged the literal "{plugin_id}"
- api_v3.py save_main_config: exception message was still leaking
  through the error response; replace with generic message (consistent
  with the rest of the CodeQL sweep in this PR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 09:29:53 -04:00
19 changed files with 1911 additions and 639 deletions

View File

@@ -22,5 +22,6 @@
"Pillow>=10.0.0", "Pillow>=10.0.0",
"PyYAML>=6.0", "PyYAML>=6.0",
"requests>=2.31.0" "requests>=2.31.0"
] ],
"local_only": true
} }

View File

@@ -67,8 +67,9 @@ def main():
print(" 📍 Will run on: http://0.0.0.0:5000") print(" 📍 Will run on: http://0.0.0.0:5000")
print(" ⏹️ Press Ctrl+C to stop") print(" ⏹️ Press Ctrl+C to stop")
# Run the app (this should start the server) # Run the app (debug mode controlled by env var to satisfy security scanners)
app.run(host='0.0.0.0', port=5000, debug=True) _debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1'
app.run(host='0.0.0.0', port=5000, debug=_debug)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n ⏹️ Server stopped by user") print("\n ⏹️ Server stopped by user")

View File

@@ -410,8 +410,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
try: try:
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8") manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
manifest = json.loads(manifest_raw) manifest = json.loads(manifest_raw)
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e: except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False, f"Invalid manifest.json: {e}", {} return False, "Invalid manifest.json", {}
if not isinstance(manifest, dict) or "schema_version" not in manifest: if not isinstance(manifest, dict) or "schema_version" not in manifest:
return False, "Invalid manifest structure", {} return False, "Invalid manifest structure", {}
@@ -456,8 +456,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
return True, "", result_manifest return True, "", result_manifest
except zipfile.BadZipFile: except zipfile.BadZipFile:
return False, "File is not a valid ZIP archive", {} return False, "File is not a valid ZIP archive", {}
except OSError as e: except OSError:
return False, f"Could not read backup: {e}", {} return False, "Could not read backup", {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -190,7 +190,7 @@ class DisplayManager:
json.dump(_hw_status, _f) json.dump(_hw_status, _f)
_f.flush() _f.flush()
os.fsync(_f.fileno()) os.fsync(_f.fileno())
os.chmod(_tmp_path, 0o600) os.chmod(_tmp_path, 0o644)
os.replace(_tmp_path, _status_path) os.replace(_tmp_path, _status_path)
except Exception: except Exception:
try: try:

View File

@@ -5,9 +5,11 @@ Handles plugin module imports, dependency installation, and class instantiation.
Extracted from PluginManager to improve separation of concerns. Extracted from PluginManager to improve separation of concerns.
""" """
import hashlib
import json import json
import importlib import importlib
import importlib.util import importlib.util
import os
import sys import sys
import subprocess import subprocess
import threading import threading
@@ -68,6 +70,11 @@ class PluginLoader:
Returns: Returns:
Path to plugin directory or None if not found Path to plugin directory or None if not found
""" """
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
return None
# Strategy 1: Use mapping from discovery # Strategy 1: Use mapping from discovery
if plugin_directories and plugin_id in plugin_directories: if plugin_directories and plugin_id in plugin_directories:
plugin_dir = plugin_directories[plugin_id] plugin_dir = plugin_directories[plugin_id]
@@ -75,14 +82,16 @@ class PluginLoader:
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir) self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
return plugin_dir return plugin_dir
# Strategy 2: Direct paths # Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
plugin_dir = plugins_dir / plugin_id plugins_dir_resolved = plugins_dir.resolve()
if plugin_dir.exists(): for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
return plugin_dir _candidate = (plugins_dir_resolved / _candidate_name).resolve()
try:
plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}" _candidate.relative_to(plugins_dir_resolved)
if plugin_dir.exists(): except ValueError:
return plugin_dir continue
if _candidate.exists():
return _candidate
# Strategy 3: Case-insensitive search # Strategy 3: Case-insensitive search
normalized_id = plugin_id.lower() normalized_id = plugin_id.lower()
@@ -130,51 +139,114 @@ class PluginLoader:
self, self,
plugin_dir: Path, plugin_dir: Path,
plugin_id: str, plugin_id: str,
plugins_dir: Optional[Path] = None,
timeout: int = 300 timeout: int = 300
) -> bool: ) -> bool:
""" """
Install plugin dependencies from requirements.txt. Install plugin dependencies from requirements.txt.
Args: Args:
plugin_dir: Plugin directory path plugin_dir: Plugin directory path
plugin_id: Plugin identifier plugin_id: Plugin identifier
plugins_dir: Trusted base plugins directory for path containment check
timeout: Installation timeout in seconds timeout: Installation timeout in seconds
Returns: Returns:
True if dependencies installed or not needed, False on error True if dependencies installed or not needed, False on error
""" """
requirements_file = plugin_dir / "requirements.txt" plugin_id = os.path.basename(plugin_id or '')
if not requirements_file.exists(): if not plugin_id:
return False
# Resolve to a canonical absolute path (normalises .. and symlinks)
plugin_dir_real = os.path.realpath(str(plugin_dir))
if plugins_dir is not None:
# Validate plugin_dir is within the trusted plugins base directory.
# os.path.realpath + startswith is the CodeQL-recognised sanitiser
# pattern for path-injection (py/path-injection).
plugins_dir_real = os.path.realpath(str(plugins_dir))
if not plugin_dir_real.startswith(plugins_dir_real + os.sep):
self.logger.error(
"Plugin dir for %s is outside the plugins directory, skipping deps",
plugin_id,
)
return False
elif not os.path.isdir(plugin_dir_real):
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
return False
requirements_file = os.path.join(plugin_dir_real, "requirements.txt")
marker_file = os.path.join(plugin_dir_real, ".dependencies_installed")
if not os.path.isfile(requirements_file):
return True # No dependencies needed return True # No dependencies needed
# Check if already installed try:
marker_path = plugin_dir / ".dependencies_installed" with open(requirements_file, 'rb') as fh:
if marker_path.exists(): current_hash = hashlib.sha256(fh.read()).hexdigest()
self.logger.debug("Dependencies already installed for %s", plugin_id) except OSError as e:
return True self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
return False
# Skip if requirements.txt hasn't changed since last install
if os.path.isfile(marker_file):
try:
with open(marker_file, 'r', encoding='utf-8') as fh:
stored_hash = fh.read().strip()
except OSError as e:
self.logger.warning(
"Could not read dependency marker for %s (%s), will reinstall dependencies",
plugin_id, e
)
else:
if stored_hash == current_hash:
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
return True
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
try: try:
self.logger.info("Installing dependencies for plugin %s...", plugin_id) self.logger.info("Installing dependencies for plugin %s...", plugin_id)
result = subprocess.run( result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)], [sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=timeout, timeout=timeout,
check=False check=False
) )
if result.returncode == 0: if result.returncode == 0:
# Mark as installed try:
marker_path.touch() with open(marker_file, 'w', encoding='utf-8') as fh:
# Set proper file permissions after creating marker fh.write(current_hash)
ensure_file_permissions(marker_path, get_plugin_file_mode()) ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
self.logger.info("Dependencies installed successfully for %s", plugin_id) self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True return True
else: else:
stderr = result.stderr or ""
# uninstall-no-record-file means the package is already present at the
# system level (e.g. installed via dnf/apt without a pip RECORD file).
# pip can't replace it, but it IS installed — write the marker so we
# don't retry on every restart.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s include system-managed packages (no pip RECORD). "
"Assuming they are satisfied: %s",
plugin_id, stderr.strip()
)
try:
with open(marker_file, 'w', encoding='utf-8') as fh:
fh.write(current_hash)
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
return True
self.logger.warning( self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s", "Dependency installation returned non-zero exit code for %s: %s",
plugin_id, plugin_id,
result.stderr stderr
) )
return False return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -349,9 +421,20 @@ class PluginLoader:
Returns: Returns:
Loaded module or None on error Loaded module or None on error
""" """
entry_file = plugin_dir / entry_point plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
raise PluginError("Invalid plugin ID")
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except OSError:
raise PluginError("Plugin directory not found", plugin_id=plugin_id)
entry_file = (plugin_dir_resolved / entry_point).resolve()
try:
entry_file.relative_to(plugin_dir_resolved)
except ValueError:
raise PluginError("Invalid entry point path", plugin_id=plugin_id)
if not entry_file.exists(): if not entry_file.exists():
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}" error_msg = f"Entry point file not found for plugin {plugin_id}"
self.logger.error(error_msg) self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)}) raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
@@ -501,11 +584,12 @@ class PluginLoader:
display_manager: Any, display_manager: Any,
cache_manager: Any, cache_manager: Any,
plugin_manager: Any, plugin_manager: Any,
install_deps: bool = True install_deps: bool = True,
plugins_dir: Optional[Path] = None,
) -> Tuple[Any, Any]: ) -> Tuple[Any, Any]:
""" """
Complete plugin loading process. Complete plugin loading process.
Args: Args:
plugin_id: Plugin identifier plugin_id: Plugin identifier
manifest: Plugin manifest manifest: Plugin manifest
@@ -515,16 +599,22 @@ class PluginLoader:
cache_manager: Cache manager instance cache_manager: Cache manager instance
plugin_manager: Plugin manager instance plugin_manager: Plugin manager instance
install_deps: Whether to install dependencies install_deps: Whether to install dependencies
plugins_dir: Trusted base plugins directory forwarded to install_dependencies
Returns: Returns:
Tuple of (plugin_instance, module) Tuple of (plugin_instance, module)
Raises: Raises:
PluginError: If loading fails PluginError: If loading fails
""" """
# Install dependencies if needed # Install dependencies if needed
if install_deps: if install_deps:
self.install_dependencies(plugin_dir, plugin_id) if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
raise PluginError(
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
plugin_id=plugin_id,
context={'plugin_dir': str(plugin_dir)},
)
# Load module # Load module
entry_point = manifest.get('entry_point', 'manager.py') entry_point = manifest.get('entry_point', 'manager.py')

View File

@@ -350,7 +350,8 @@ class PluginManager:
display_manager=self.display_manager, display_manager=self.display_manager,
cache_manager=self.cache_manager, cache_manager=self.cache_manager,
plugin_manager=self, plugin_manager=self,
install_deps=True install_deps=True,
plugins_dir=self.plugins_dir,
) )
# Store module # Store module

View File

@@ -185,13 +185,19 @@ class StateReconciliation:
message=f"Reconciliation failed: {str(e)}" message=f"Reconciliation failed: {str(e)}"
) )
# Top-level config keys that are NOT plugins # Top-level config keys that are NOT plugins.
# Includes both config.json structural keys and config_secrets.json top-level
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
_SYSTEM_CONFIG_KEYS = frozenset({ _SYSTEM_CONFIG_KEYS = frozenset({
'web_display_autostart', 'timezone', 'location', 'display', 'web_display_autostart', 'timezone', 'location', 'display',
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width', 'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging', 'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
'dim_schedule', 'network', 'system', 'schedule', 'dim_schedule', 'network', 'system', 'schedule',
# Multi-display sync config (config.json structural key)
'sync',
# Secrets file top-level keys (merged in by load_config)
'github', 'youtube',
}) })
def _get_config_state(self) -> Dict[str, Dict[str, Any]]: def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
@@ -334,15 +340,15 @@ class StateReconciliation:
# Check: Enabled state mismatch # Check: Enabled state mismatch
config_enabled = config.get('enabled', False) config_enabled = config.get('enabled', False)
state_mgr_enabled = state_mgr.get('enabled') state_mgr_enabled = state_mgr.get('enabled')
if state_mgr_enabled is not None and config_enabled != state_mgr_enabled: if state_mgr_enabled is not None and config_enabled != state_mgr_enabled:
inconsistencies.append(Inconsistency( inconsistencies.append(Inconsistency(
plugin_id=plugin_id, plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH, inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}", description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
fix_action=FixAction.AUTO_FIX, fix_action=FixAction.AUTO_FIX,
current_state={'enabled': config_enabled}, current_state={'enabled': state_mgr_enabled},
expected_state={'enabled': state_mgr_enabled}, expected_state={'enabled': config_enabled},
can_auto_fix=True can_auto_fix=True
)) ))
@@ -365,15 +371,23 @@ class StateReconciliation:
return self._auto_repair_missing_plugin(inconsistency.plugin_id) return self._auto_repair_missing_plugin(inconsistency.plugin_id)
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH: elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
# Sync enabled state from state manager to config # config.json is the user-editable source of truth for enabled state.
expected_enabled = inconsistency.expected_state.get('enabled') # Bring the state manager in sync with config rather than the reverse,
config = self.config_manager.load_config() # so that manual config edits (or the state left behind after an
if inconsistency.plugin_id not in config: # uninstall+reinstall cycle) don't silently override the user's intent.
config[inconsistency.plugin_id] = {} config_enabled = inconsistency.expected_state.get('enabled')
config[inconsistency.plugin_id]['enabled'] = expected_enabled success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
self.config_manager.save_config(config) if success:
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}") self.logger.info(
return True f"Fixed: Synced state manager enabled={config_enabled} for "
f"{inconsistency.plugin_id} to match config"
)
else:
self.logger.warning(
f"Failed to sync state manager enabled={config_enabled} for "
f"{inconsistency.plugin_id}"
)
return success
except Exception as e: except Exception as e:
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True) self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)

View File

@@ -5,6 +5,7 @@ Handles plugin discovery, installation, updates, and uninstallation
from both the official registry and custom GitHub repositories. from both the official registry and custom GitHub repositories.
""" """
import hashlib
import os import os
import json import json
import stat import stat
@@ -21,6 +22,8 @@ from pathlib import Path
from typing import List, Dict, Optional, Any, Tuple from typing import List, Dict, Optional, Any, Tuple
import logging import logging
from urllib.parse import urlparse
from src.common.permission_utils import sudo_remove_directory from src.common.permission_utils import sudo_remove_directory
try: try:
@@ -356,7 +359,8 @@ class PluginStoreManager:
# Extract owner/repo from URL # Extract owner/repo from URL
try: try:
# Handle different URL formats # Handle different URL formats
if 'github.com' in repo_url: _parsed_url = urlparse(repo_url)
if _parsed_url.hostname in ('github.com', 'www.github.com'):
parts = repo_url.strip('/').split('/') parts = repo_url.strip('/').split('/')
if len(parts) >= 2: if len(parts) >= 2:
owner = parts[-2] owner = parts[-2]
@@ -518,9 +522,10 @@ class PluginStoreManager:
# Try to find plugins.json in common locations # Try to find plugins.json in common locations
# First try root directory # First try root directory
registry_urls = [] registry_urls = []
# Extract owner/repo from URL # Extract owner/repo from URL
if 'github.com' in repo_url: _parsed_repo_url = urlparse(repo_url)
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
parts = repo_url.split('/') parts = repo_url.split('/')
if len(parts) >= 2: if len(parts) >= 2:
owner = parts[-2] owner = parts[-2]
@@ -775,7 +780,8 @@ class PluginStoreManager:
try: try:
# Convert repo URL to raw content URL # Convert repo URL to raw content URL
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json # https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
if 'github.com' in repo_url: _parsed_manifest_url = urlparse(repo_url)
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
# Handle different URL formats # Handle different URL formats
repo_url = repo_url.rstrip('/') repo_url = repo_url.rstrip('/')
if repo_url.endswith('.git'): if repo_url.endswith('.git'):
@@ -1750,6 +1756,12 @@ class PluginStoreManager:
timeout=300 timeout=300
) )
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}") self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
# Write hash marker so plugin_loader skips redundant pip run on next startup
try:
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
return True return True
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:

View File

@@ -150,6 +150,18 @@ class WiFiManager:
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, " logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, " f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}") f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
# Once per process: remove a stale force-AP flag left by a prior crash.
# Guard with a class-level flag so the nmcli AP-state check only runs
# once even though WiFiManager is instantiated per-request.
if not WiFiManager._startup_cleanup_done:
WiFiManager._startup_cleanup_done = True
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
try:
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.debug("Removed stale force-AP flag on startup (AP not active)")
except OSError as exc:
logger.warning(f"Could not remove stale force-AP flag: {exc}")
def _show_led_message(self, message: str, duration: int = 5): def _show_led_message(self, message: str, duration: int = 5):
""" """
@@ -474,7 +486,10 @@ class WiFiManager:
if result.returncode == 0: if result.returncode == 0:
for line in result.stdout.strip().split('\n'): for line in result.stdout.strip().split('\n'):
if '/' in line: if '/' in line:
ip_address = line.split('/')[0].strip() # nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
# bare "x.x.x.x/prefix" is also accepted defensively.
_, sep, rest = line.partition(':')
ip_address = (rest if sep else line).split('/')[0].strip()
break break
# Final fallback: Get signal strength by matching SSID in WiFi list # Final fallback: Get signal strength by matching SSID in WiFi list
@@ -500,6 +515,13 @@ class WiFiManager:
# Check if AP mode is active # Check if AP mode is active
ap_active = self._is_ap_mode_active() ap_active = self._is_ap_mode_active()
# wlan0 shows as "connected" in AP mode; clear client-station fields so
# callers don't mistake the AP for an outbound WiFi connection.
if ap_active and wifi_connected:
wifi_connected = False
ssid = None
ip_address = None
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
return WiFiStatus( return WiFiStatus(
connected=wifi_connected, connected=wifi_connected,
@@ -690,6 +712,10 @@ class WiFiManager:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi _IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
_startup_cleanup_done: bool = False
def _validate_ap_config(self) -> Tuple[str, int]: def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults.""" """Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
@@ -1367,7 +1393,7 @@ class WiFiManager:
logger.error(f"Failed to restore original connection: {original_ssid}") logger.error(f"Failed to restore original connection: {original_ssid}")
# Trigger AP mode as last resort # Trigger AP mode as last resort
self._show_led_message("Enabling AP mode...", duration=5) self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode() ap_success, ap_msg = self.enable_ap_mode(force=True)
if ap_success: if ap_success:
logger.info("AP mode enabled as failsafe") logger.info("AP mode enabled as failsafe")
return False, "Connection failed and restoration failed. AP mode enabled." return False, "Connection failed and restoration failed. AP mode enabled."
@@ -1379,7 +1405,7 @@ class WiFiManager:
elif not success: elif not success:
logger.warning(f"Connection to {ssid} failed and no original connection to restore") logger.warning(f"Connection to {ssid} failed and no original connection to restore")
self._show_led_message("Enabling AP mode...", duration=5) self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode() ap_success, ap_msg = self.enable_ap_mode(force=True)
if ap_success: if ap_success:
logger.info("AP mode enabled as failsafe") logger.info("AP mode enabled as failsafe")
return False, "Connection failed. AP mode enabled." return False, "Connection failed. AP mode enabled."
@@ -1400,7 +1426,7 @@ class WiFiManager:
logger.error(f"Failed to restore after exception: {restore_error}") logger.error(f"Failed to restore after exception: {restore_error}")
# Last resort: enable AP mode # Last resort: enable AP mode
try: try:
self.enable_ap_mode() self.enable_ap_mode(force=True)
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True) logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
return False, str(e) return False, str(e)
@@ -1464,26 +1490,29 @@ class WiFiManager:
# Show LED message # Show LED message
self._show_led_message(f"Connecting to {ssid}...", duration=10) self._show_led_message(f"Connecting to {ssid}...", duration=10)
# First, check if connection already exists and try to activate it # Find existing NM connection for this SSID.
# NetworkManager connection names might not match SSID exactly, so search by SSID # 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
check_result = subprocess.run( # so list all wifi connections then query each one's SSID individually.
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"], list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
capture_output=True, ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
text=True, capture_output=True, text=True, timeout=5
timeout=5
) )
existing_conn_name = None existing_conn_name = None
if check_result.returncode == 0: if list_result.returncode == 0:
for line in check_result.stdout.strip().split('\n'): for line in list_result.stdout.strip().split('\n'):
if ':' in line: if ':' not in line:
parts = line.split(':') continue
if len(parts) >= 2: parts = line.split(':')
conn_name = parts[0].strip() if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
conn_ssid = parts[1].strip() if len(parts) > 1 else "" continue
if conn_ssid == ssid: conn_name = parts[0].strip()
existing_conn_name = conn_name ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
break ["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
capture_output=True, text=True, timeout=5
)
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
existing_conn_name = conn_name
break
# Also try direct lookup by SSID (in case connection name matches SSID) # Also try direct lookup by SSID (in case connection name matches SSID)
if not existing_conn_name: if not existing_conn_name:
@@ -1855,7 +1884,7 @@ class WiFiManager:
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts") logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
return False return False
def enable_ap_mode(self) -> Tuple[bool, str]: def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
""" """
Enable access point mode Enable access point mode
@@ -1877,20 +1906,29 @@ class WiFiManager:
if not self._ensure_wifi_radio_enabled(): if not self._ensure_wifi_radio_enabled():
return False, "WiFi radio is disabled and could not be enabled" return False, "WiFi radio is disabled and could not be enabled"
# Check if WiFi is connected # Check if WiFi is connected (skip when force=True)
status = self.get_wifi_status() status = self.get_wifi_status()
if status.connected: if not force and status.connected:
return False, "Cannot enable AP mode while WiFi is connected" return False, "Cannot enable AP mode while WiFi is connected"
# Check if Ethernet is connected # Check if Ethernet is connected (skip when force=True)
if self._is_ethernet_connected(): if not force and self._is_ethernet_connected():
return False, "Cannot enable AP mode while Ethernet is connected" return False, "Cannot enable AP mode while Ethernet is connected"
if force:
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
# Try hostapd/dnsmasq first (captive portal mode) # Try hostapd/dnsmasq first (captive portal mode)
if self.has_hostapd and self.has_dnsmasq: if self.has_hostapd and self.has_dnsmasq:
result = self._enable_ap_mode_hostapd() result = self._enable_ap_mode_hostapd()
if result[0]: if result[0]:
self._ap_enabled_at = time.time() self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result return result
# Fallback to nmcli hotspot (simpler, no captive portal) # Fallback to nmcli hotspot (simpler, no captive portal)
@@ -1900,6 +1938,12 @@ class WiFiManager:
result = self._enable_ap_mode_nmcli_hotspot() result = self._enable_ap_mode_nmcli_hotspot()
if result[0]: if result[0]:
self._ap_enabled_at = time.time() self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result return result
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)" return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
@@ -2091,8 +2135,14 @@ class WiFiManager:
self._clear_led_message() self._clear_led_message()
return False, "AP started but captive-portal redirect setup failed" return False, "AP started but captive-portal redirect setup failed"
# Verify the AP is actually running # Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
status = self._get_ap_status_nmcli() status = {}
for _attempt in range(5):
status = self._get_ap_status_nmcli()
if status.get('active'):
break
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
time.sleep(2)
if status.get('active'): if status.get('active'):
ip = status.get('ip', '192.168.4.1') ip = status.get('ip', '192.168.4.1')
logger.info(f"AP mode confirmed active at {ip} (open network, no password)") logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
@@ -2290,6 +2340,7 @@ class WiFiManager:
logger.warning("WiFi radio may be disabled after nmcli AP cleanup") logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
self._ap_enabled_at = None self._ap_enabled_at = None
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.info("AP mode disabled successfully") logger.info("AP mode disabled successfully")
return True, "AP mode disabled" return True, "AP mode disabled"
except Exception as e: except Exception as e:
@@ -2478,22 +2529,29 @@ address=/detectportal.firefox.com/192.168.4.1
else: else:
logger.warning(f"Failed to enable AP mode: {message}") logger.warning(f"Failed to enable AP mode: {message}")
elif not should_have_ap and ap_active: elif not should_have_ap and ap_active:
# Should not have AP but do - disable AP mode # Should not have AP but do - check if it was manually force-enabled
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting force_active = self._FORCE_AP_FLAG_PATH.exists()
if status.connected or ethernet_connected: if status.connected:
# WiFi connected: always disable AP (user successfully configured WiFi)
success, message = self.disable_ap_mode() success, message = self.disable_ap_mode()
if success: if success:
if status.connected: logger.info("Auto-disabled AP mode (WiFi connected)")
logger.info("Auto-disabled AP mode (WiFi connected)") self._disconnected_checks = 0
elif ethernet_connected:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0 # Reset counter
return True return True
else: else:
logger.warning(f"Failed to auto-disable AP mode: {message}") logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and not force_active:
# Ethernet connected, AP not manually forced: auto-disable
success, message = self.disable_ap_mode()
if success:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and force_active:
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
elif not auto_enable: elif not auto_enable:
# AP is active but auto_enable is disabled - this means it was manually enabled
# Don't disable it automatically, let it stay active
logger.debug("AP mode is active (manually enabled), keeping active") logger.debug("AP mode is active (manually enabled), keeping active")
# Idle-timeout check: disable AP if no client has connected within the window. # Idle-timeout check: disable AP if no client has connected within the window.

View File

@@ -2,8 +2,11 @@ from flask import Flask, request, redirect, url_for, jsonify, Response, send_fro
import json import json
import logging import logging
import os import os
import queue
import shutil
import sys import sys
import subprocess import subprocess
import threading
import time import time
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -22,6 +25,9 @@ from src.plugin_system.state_manager import PluginStateManager
from src.plugin_system.operation_history import OperationHistory from src.plugin_system.operation_history import OperationHistory
from src.plugin_system.health_monitor import PluginHealthMonitor from src.plugin_system.health_monitor import PluginHealthMonitor
_JOURNALCTL = shutil.which('journalctl')
_SYSTEMCTL = shutil.which('systemctl')
# Create Flask app # Create Flask app
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.urandom(24) app.secret_key = os.urandom(24)
@@ -204,24 +210,12 @@ def serve_plugin_asset(plugin_id, filename):
# Use send_from_directory to serve the file # Use send_from_directory to serve the file
return send_from_directory(str(assets_dir), filename, mimetype=content_type) return send_from_directory(str(assets_dir), filename, mimetype=content_type)
except Exception as e: except Exception:
# Log the exception with full traceback server-side
import traceback
app.logger.exception('Error serving plugin asset file') app.logger.exception('Error serving plugin asset file')
return jsonify({
# Return generic error message to client (avoid leaking internal details) 'status': 'error',
# Only include detailed error information when in debug mode 'message': 'Internal server error'
if app.debug: }), 500
return jsonify({
'status': 'error',
'message': str(e),
'traceback': traceback.format_exc()
}), 500
else:
return jsonify({
'status': 'error',
'message': 'Internal server error'
}), 500
# Prime psutil CPU measurement once at startup so interval=None returns a real value # Prime psutil CPU measurement once at startup so interval=None returns a real value
try: try:
@@ -342,35 +336,25 @@ def not_found_error(error):
@app.errorhandler(500) @app.errorhandler(500)
def internal_error(error): def internal_error(error):
"""Handle 500 errors.""" """Handle 500 errors."""
import traceback
error_details = traceback.format_exc()
# Log the error
import logging import logging
logger = logging.getLogger('web_interface') logger = logging.getLogger('web_interface')
logger.error(f"Internal server error: {error}", exc_info=True) logger.error("Internal server error", exc_info=True)
# Return user-friendly error (hide internal details in production)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'error_code': 'INTERNAL_ERROR', 'error_code': 'INTERNAL_ERROR',
'message': 'An internal error occurred', 'message': 'An internal error occurred; see logs for details',
'details': error_details if app.debug else None
}), 500 }), 500
@app.errorhandler(Exception) @app.errorhandler(Exception)
def handle_exception(error): def handle_exception(error):
"""Handle all unhandled exceptions.""" """Handle all unhandled exceptions."""
import traceback
import logging import logging
logger = logging.getLogger('web_interface') logger = logging.getLogger('web_interface')
logger.error(f"Unhandled exception: {error}", exc_info=True) logger.error("Unhandled exception", exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'error_code': 'UNKNOWN_ERROR', 'error_code': 'UNKNOWN_ERROR',
'message': str(error) if app.debug else 'An error occurred', 'message': 'An error occurred; see logs for details',
'details': traceback.format_exc() if app.debug else None
}), 500 }), 500
# Captive portal redirect middleware # Captive portal redirect middleware
@@ -435,13 +419,53 @@ def add_security_headers(response):
return response return response
# SSE helper function class _StreamBroadcaster:
def sse_response(generator_func): """Fan-out broadcaster: one background generator thread pushes to all SSE clients.
"""Helper to create SSE responses"""
def generate(): This means N browser tabs share one generator instead of each running their own,
for data in generator_func(): keeping PIL encodes / subprocess forks constant regardless of how many tabs are open.
yield f"data: {json.dumps(data)}\n\n" """
return Response(generate(), mimetype='text/event-stream')
def __init__(self, generator_factory):
self._generator_factory = generator_factory
self._clients: set = set()
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=5)
with self._lock:
self._clients.add(q)
if not (self._thread and self._thread.is_alive()):
self._thread = threading.Thread(target=self._broadcast, daemon=True)
self._thread.start()
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
self._clients.discard(q)
def _broadcast(self):
for data in self._generator_factory():
with self._lock:
if not self._clients:
# No subscribers — exit so the thread doesn't spin indefinitely.
# subscribe() will restart it when a new client arrives.
break
for q in self._clients:
try:
q.put_nowait(data)
except queue.Full:
# Client is reading too slowly; drop the oldest item and
# deliver the latest so the queue never stalls the client.
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(data)
except queue.Full:
pass
# System status generator for SSE # System status generator for SSE
def system_status_generator(): def system_status_generator():
@@ -472,12 +496,13 @@ def system_status_generator():
# Check if display service is running (cached to avoid per-client subprocess forks) # Check if display service is running (cached to avoid per-client subprocess forks)
now = time.time() now = time.time()
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL: if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
try: if _SYSTEMCTL:
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'], try:
capture_output=True, text=True, timeout=2) result = subprocess.run([_SYSTEMCTL, 'is-active', 'ledmatrix'],
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active' capture_output=True, text=True, timeout=2)
except (subprocess.SubprocessError, OSError): _ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
pass except (subprocess.SubprocessError, OSError) as e:
app.logger.warning("systemctl status check failed: %s", e)
_ledmatrix_service_cache['timestamp'] = now _ledmatrix_service_cache['timestamp'] = now
service_active = _ledmatrix_service_cache['active'] service_active = _ledmatrix_service_cache['active']
@@ -492,7 +517,8 @@ def system_status_generator():
} }
yield status yield status
except Exception as e: except Exception as e:
yield {'error': str(e)} app.logger.error("SSE generator error", exc_info=True)
yield {'error': 'An error occurred; see server logs'}
time.sleep(10) # Update every 10 seconds (reduced frequency for better performance) time.sleep(10) # Update every 10 seconds (reduced frequency for better performance)
# Display preview generator for SSE # Display preview generator for SSE
@@ -555,7 +581,8 @@ def display_preview_generator():
} }
except Exception as e: except Exception as e:
yield {'error': str(e)} app.logger.error("SSE generator error", exc_info=True)
yield {'error': 'An error occurred; see server logs'}
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
@@ -567,8 +594,13 @@ def logs_generator():
# Get recent logs from journalctl (simplified version) # Get recent logs from journalctl (simplified version)
# Note: User should be in systemd-journal group to read logs without sudo # Note: User should be in systemd-journal group to read logs without sudo
try: try:
if not _JOURNALCTL:
yield {'timestamp': time.time(), 'logs': 'journalctl not found; cannot read logs'}
time.sleep(60)
continue
result = subprocess.run( result = subprocess.run(
['journalctl', '-u', 'ledmatrix.service', '-n', '50', '--no-pager'], [_JOURNALCTL, '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
'-n', '50', '--no-pager', '--output=short-iso'],
capture_output=True, text=True, timeout=5 capture_output=True, text=True, timeout=5
) )
@@ -584,7 +616,7 @@ def logs_generator():
# No logs available # No logs available
logs_data = { logs_data = {
'timestamp': time.time(), 'timestamp': time.time(),
'logs': 'No logs available from ledmatrix service' 'logs': 'No logs available from ledmatrix or ledmatrix-web service'
} }
yield logs_data yield logs_data
else: else:
@@ -598,36 +630,68 @@ def logs_generator():
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
# Timeout - just skip this update # Timeout - just skip this update
pass pass
except Exception as e: except Exception:
app.logger.error("Error running journalctl", exc_info=True)
error_data = { error_data = {
'timestamp': time.time(), 'timestamp': time.time(),
'logs': f'Error running journalctl: {str(e)}' 'logs': 'Error running journalctl; see server logs'
} }
yield error_data yield error_data
except Exception as e: except Exception:
app.logger.error("Unexpected error in logs generator", exc_info=True)
error_data = { error_data = {
'timestamp': time.time(), 'timestamp': time.time(),
'logs': f'Unexpected error in logs generator: {str(e)}' 'logs': 'Unexpected error in logs generator; see server logs'
} }
yield error_data yield error_data
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance) time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
# One broadcaster per stream — shared across all SSE clients
_stats_broadcaster = _StreamBroadcaster(system_status_generator)
_display_broadcaster = _StreamBroadcaster(display_preview_generator)
_logs_broadcaster = _StreamBroadcaster(logs_generator)
def _sse_stream(broadcaster: _StreamBroadcaster) -> Response:
"""Return a streaming SSE response backed by a shared broadcaster."""
q = broadcaster.subscribe()
def generate():
try:
while True:
try:
data = q.get(timeout=30)
yield f"data: {json.dumps(data)}\n\n"
except queue.Empty:
# Send an SSE comment heartbeat to keep the connection alive
# through proxies that close idle connections.
yield ": heartbeat\n\n"
except GeneratorExit:
pass
finally:
broadcaster.unsubscribe(q)
return Response(generate(), mimetype='text/event-stream')
# SSE endpoints # SSE endpoints
@app.route('/api/v3/stream/stats') @app.route('/api/v3/stream/stats')
def stream_stats(): def stream_stats():
return sse_response(system_status_generator) return _sse_stream(_stats_broadcaster)
@app.route('/api/v3/stream/display') @app.route('/api/v3/stream/display')
def stream_display(): def stream_display():
return sse_response(display_preview_generator) return _sse_stream(_display_broadcaster)
@app.route('/api/v3/stream/logs') @app.route('/api/v3/stream/logs')
def stream_logs(): def stream_logs():
return sse_response(logs_generator) return _sse_stream(_logs_broadcaster)
# Exempt SSE streams from CSRF and add rate limiting # Exempt SSE streams from CSRF and apply a generous rate limit.
# SSE connections are long-lived HTTP requests, not repeated API calls, so the
# tight "20 per minute" default would be exhausted quickly on reconnects.
if csrf: if csrf:
csrf.exempt(stream_stats) csrf.exempt(stream_stats)
csrf.exempt(stream_display) csrf.exempt(stream_display)
@@ -635,9 +699,9 @@ if csrf:
# Note: api_v3 blueprint is exempted above after registration # Note: api_v3 blueprint is exempted above after registration
if limiter: if limiter:
limiter.limit("20 per minute")(stream_stats) limiter.limit("200 per minute")(stream_stats)
limiter.limit("20 per minute")(stream_display) limiter.limit("200 per minute")(stream_display)
limiter.limit("20 per minute")(stream_logs) limiter.limit("200 per minute")(stream_logs)
# Main route - redirect to v3 interface as default # Main route - redirect to v3 interface as default
@app.route('/') @app.route('/')

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ from flask import Blueprint, render_template, flash
from markupsafe import escape from markupsafe import escape
import json import json
import logging import logging
import os
import re
from pathlib import Path from pathlib import Path
from src.web_interface.secret_helpers import mask_secret_fields from src.web_interface.secret_helpers import mask_secret_fields
@@ -84,10 +86,11 @@ def load_partial(partial_name):
elif partial_name == 'operation-history': elif partial_name == 'operation-history':
return _load_operation_history_partial() return _load_operation_history_partial()
else: else:
return f"Partial '{partial_name}' not found", 404 return "Partial not found", 404
except Exception as e: except Exception as e:
return f"Error loading partial '{partial_name}': {str(e)}", 500 logger.error("Error loading partial %s", partial_name, exc_info=True)
return "Error loading partial", 500
@pages_v3.route('/partials/plugin-config/<plugin_id>') @pages_v3.route('/partials/plugin-config/<plugin_id>')
@@ -95,8 +98,9 @@ def load_plugin_config_partial(plugin_id):
"""Load plugin configuration partial via HTMX - server-side rendered form""" """Load plugin configuration partial via HTMX - server-side rendered form"""
try: try:
return _load_plugin_config_partial(plugin_id) return _load_plugin_config_partial(plugin_id)
except Exception as e: except Exception:
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500 logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
def _load_overview_partial(): def _load_overview_partial():
"""Load overview partial with system stats""" """Load overview partial with system stats"""
@@ -107,7 +111,8 @@ def _load_overview_partial():
return render_template('v3/partials/overview.html', return render_template('v3/partials/overview.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_general_partial(): def _load_general_partial():
"""Load general settings partial""" """Load general settings partial"""
@@ -117,7 +122,8 @@ def _load_general_partial():
return render_template('v3/partials/general.html', return render_template('v3/partials/general.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_display_partial(): def _load_display_partial():
"""Load display settings partial""" """Load display settings partial"""
@@ -127,7 +133,8 @@ def _load_display_partial():
return render_template('v3/partials/display.html', return render_template('v3/partials/display.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_durations_partial(): def _load_durations_partial():
"""Load display durations partial""" """Load display durations partial"""
@@ -137,7 +144,8 @@ def _load_durations_partial():
return render_template('v3/partials/durations.html', return render_template('v3/partials/durations.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_schedule_partial(): def _load_schedule_partial():
"""Load schedule settings partial""" """Load schedule settings partial"""
@@ -153,7 +161,8 @@ def _load_schedule_partial():
dim_schedule_config=dim_schedule_config, dim_schedule_config=dim_schedule_config,
normal_brightness=normal_brightness) normal_brightness=normal_brightness)
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_weather_partial(): def _load_weather_partial():
@@ -164,7 +173,8 @@ def _load_weather_partial():
return render_template('v3/partials/weather.html', return render_template('v3/partials/weather.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_stocks_partial(): def _load_stocks_partial():
"""Load stocks configuration partial""" """Load stocks configuration partial"""
@@ -174,7 +184,8 @@ def _load_stocks_partial():
return render_template('v3/partials/stocks.html', return render_template('v3/partials/stocks.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_plugins_partial(): def _load_plugins_partial():
"""Load plugins management partial""" """Load plugins management partial"""
@@ -208,7 +219,7 @@ def _load_plugins_partial():
plugin_info.update(fresh_manifest) plugin_info.update(fresh_manifest)
except Exception as e: except Exception as e:
# If we can't read the fresh manifest, use the cached one # If we can't read the fresh manifest, use the cached one
print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}") logger.warning("Could not read fresh manifest for plugin: %s", plugin_id)
# Get enabled status from config (source of truth) # Get enabled status from config (source of truth)
# Read from config file first, fall back to plugin instance if config doesn't have the key # Read from config file first, fall back to plugin instance if config doesn't have the key
@@ -256,12 +267,13 @@ def _load_plugins_partial():
'branch': branch 'branch': branch
}) })
except Exception as e: except Exception as e:
print(f"Error loading plugin data: {e}") logger.error("Error loading plugin data", exc_info=True)
return render_template('v3/partials/plugins.html', return render_template('v3/partials/plugins.html',
plugins=plugins_data) plugins=plugins_data)
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_fonts_partial(): def _load_fonts_partial():
"""Load fonts management partial""" """Load fonts management partial"""
@@ -271,14 +283,16 @@ def _load_fonts_partial():
return render_template('v3/partials/fonts.html', return render_template('v3/partials/fonts.html',
fonts=fonts_data) fonts=fonts_data)
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_logs_partial(): def _load_logs_partial():
"""Load logs viewer partial""" """Load logs viewer partial"""
try: try:
return render_template('v3/partials/logs.html') return render_template('v3/partials/logs.html')
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_raw_json_partial(): def _load_raw_json_partial():
"""Load raw JSON editor partial""" """Load raw JSON editor partial"""
@@ -295,14 +309,16 @@ def _load_raw_json_partial():
main_config_path=pages_v3.config_manager.get_config_path(), main_config_path=pages_v3.config_manager.get_config_path(),
secrets_config_path=pages_v3.config_manager.get_secrets_path()) secrets_config_path=pages_v3.config_manager.get_secrets_path())
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_backup_restore_partial(): def _load_backup_restore_partial():
"""Load backup & restore partial.""" """Load backup & restore partial."""
try: try:
return render_template('v3/partials/backup_restore.html') return render_template('v3/partials/backup_restore.html')
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
@pages_v3.route('/setup') @pages_v3.route('/setup')
def captive_setup(): def captive_setup():
@@ -314,21 +330,24 @@ def _load_wifi_partial():
try: try:
return render_template('v3/partials/wifi.html') return render_template('v3/partials/wifi.html')
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_cache_partial(): def _load_cache_partial():
"""Load cache management partial""" """Load cache management partial"""
try: try:
return render_template('v3/partials/cache.html') return render_template('v3/partials/cache.html')
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_operation_history_partial(): def _load_operation_history_partial():
"""Load operation history partial""" """Load operation history partial"""
try: try:
return render_template('v3/partials/operation_history.html') return render_template('v3/partials/operation_history.html')
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_plugin_config_partial(plugin_id): def _load_plugin_config_partial(plugin_id):
@@ -336,6 +355,11 @@ def _load_plugin_config_partial(plugin_id):
Load plugin configuration partial - server-side rendered form. Load plugin configuration partial - server-side rendered form.
This replaces the client-side generateConfigForm() JavaScript. This replaces the client-side generateConfigForm() JavaScript.
""" """
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
plugin_id = os.path.basename(plugin_id or '')
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
try: try:
if not pages_v3.plugin_manager: if not pages_v3.plugin_manager:
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500 return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
@@ -344,80 +368,85 @@ def _load_plugin_config_partial(plugin_id):
if plugin_id.startswith('starlark:'): if plugin_id.startswith('starlark:'):
return _load_starlark_config_partial(plugin_id[len('starlark:'):]) return _load_starlark_config_partial(plugin_id[len('starlark:'):])
# Resolve and validate all plugin paths against the plugins base directory
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
_plugin_dir = (_plugins_base / plugin_id).resolve()
try:
_plugin_dir.relative_to(_plugins_base)
except ValueError:
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
# Try to get plugin info first # Try to get plugin info first
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id) plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
# If not found, re-discover plugins (handles plugins added after startup) # If not found, re-discover plugins (handles plugins added after startup)
if not plugin_info: if not plugin_info:
pages_v3.plugin_manager.discover_plugins() pages_v3.plugin_manager.discover_plugins()
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id) plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
if not plugin_info: if not plugin_info:
return f'<div class="text-red-500 p-4">Plugin "{escape(plugin_id)}" not found</div>', 404 return '<div class="text-red-500 p-4">Plugin not found</div>', 404
# Get plugin instance (may be None if not loaded) # Get plugin instance (may be None if not loaded)
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id) plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
# Get plugin configuration from config file # Get plugin configuration from config file
config = {} config = {}
if pages_v3.config_manager: if pages_v3.config_manager:
full_config = pages_v3.config_manager.load_config() full_config = pages_v3.config_manager.load_config()
config = full_config.get(plugin_id, {}) config = full_config.get(plugin_id, {})
# Load uploaded images from metadata file if images field exists in schema # Load uploaded images from metadata file if images field exists in schema
# This ensures uploaded images appear even if config hasn't been saved yet schema_path_temp = _plugin_dir / "config_schema.json"
schema_path_temp = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
if schema_path_temp.exists(): if schema_path_temp.exists():
try: try:
with open(schema_path_temp, 'r', encoding='utf-8') as f: with open(schema_path_temp, 'r', encoding='utf-8') as f:
temp_schema = json.load(f) temp_schema = json.load(f)
# Check if schema has an images field with x-widget: file-upload
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'): temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
# Load metadata file _assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
# Get PROJECT_ROOT relative to this file metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve()
project_root = Path(__file__).parent.parent.parent try:
metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json' metadata_file.relative_to(_assets_base)
if metadata_file.exists(): except ValueError:
metadata_file = None
if metadata_file and metadata_file.exists():
try: try:
with open(metadata_file, 'r', encoding='utf-8') as mf: with open(metadata_file, 'r', encoding='utf-8') as mf:
metadata = json.load(mf) metadata = json.load(mf)
# Convert metadata dict to list of image objects
images_from_metadata = list(metadata.values()) images_from_metadata = list(metadata.values())
# Only use metadata images if config doesn't have images or config images is empty
if not config.get('images') or len(config.get('images', [])) == 0: if not config.get('images') or len(config.get('images', [])) == 0:
config['images'] = images_from_metadata config['images'] = images_from_metadata
else: else:
# Merge: add metadata images that aren't already in config
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')} config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids] new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
if new_images: if new_images:
config['images'] = config.get('images', []) + new_images config['images'] = config.get('images', []) + new_images
except Exception as e: except Exception as e:
print(f"Warning: Could not load metadata for {plugin_id}: {e}") logger.warning("Could not load plugin upload metadata: %s", e)
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e) logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
# Get plugin schema # Get plugin schema
schema = {} schema = {}
schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json" schema_path = _plugin_dir / "config_schema.json"
if schema_path.exists(): if schema_path.exists():
try: try:
with open(schema_path, 'r', encoding='utf-8') as f: with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f) schema = json.load(f)
except Exception as e: except Exception as e:
print(f"Warning: Could not load schema for {plugin_id}: {e}") logger.warning("Could not load schema for plugin: %s", e)
# Get web UI actions from plugin manifest # Get web UI actions from plugin manifest
web_ui_actions = [] web_ui_actions = []
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json" manifest_path = _plugin_dir / "manifest.json"
if manifest_path.exists(): if manifest_path.exists():
try: try:
with open(manifest_path, 'r', encoding='utf-8') as f: with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f) manifest = json.load(f)
web_ui_actions = manifest.get('web_ui_actions', []) web_ui_actions = manifest.get('web_ui_actions', [])
except Exception as e: except Exception as e:
print(f"Warning: Could not load manifest for {plugin_id}: {e}") logger.warning("Could not load manifest for plugin: %s", e)
# Mask secret fields before rendering template (fail closed — never leak secrets) # Mask secret fields before rendering template (fail closed — never leak secrets)
schema_properties = schema.get('properties') if isinstance(schema, dict) else None schema_properties = schema.get('properties') if isinstance(schema, dict) else None
@@ -453,20 +482,24 @@ def _load_plugin_config_partial(plugin_id):
) )
except Exception as e: except Exception as e:
import traceback logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
traceback.print_exc() return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
def _load_starlark_config_partial(app_id): def _load_starlark_config_partial(app_id):
"""Load configuration partial for a Starlark app.""" """Load configuration partial for a Starlark app."""
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
app_id = os.path.basename(app_id or '')
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
try: try:
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
if starlark_plugin and hasattr(starlark_plugin, 'apps'): if starlark_plugin and hasattr(starlark_plugin, 'apps'):
app = starlark_plugin.apps.get(app_id) app = starlark_plugin.apps.get(app_id)
if not app: if not app:
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404 return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
return render_template( return render_template(
'v3/partials/starlark_config.html', 'v3/partials/starlark_config.html',
app_id=app_id, app_id=app_id,
@@ -482,36 +515,45 @@ def _load_starlark_config_partial(app_id):
) )
# Standalone: read from manifest file # Standalone: read from manifest file
manifest_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / 'manifest.json' starlark_base = (Path(__file__).resolve().parent.parent.parent / 'starlark-apps').resolve()
manifest_file = starlark_base / 'manifest.json'
if not manifest_file.exists(): if not manifest_file.exists():
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404 return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
with open(manifest_file, 'r') as f: with open(manifest_file, 'r') as f:
manifest = json.load(f) manifest = json.load(f)
app_data = manifest.get('apps', {}).get(app_id) app_data = manifest.get('apps', {}).get(app_id)
if not app_data: if not app_data:
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404 return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
# Load schema from schema.json if it exists # Load schema from schema.json if it exists — validate path stays within starlark_base
schema = None schema = None
schema_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'schema.json' schema_file = (starlark_base / app_id / 'schema.json').resolve()
if schema_file.exists(): try:
schema_file.relative_to(starlark_base)
except ValueError:
schema_file = None
if schema_file and schema_file.exists():
try: try:
with open(schema_file, 'r') as f: with open(schema_file, 'r') as f:
schema = json.load(f) schema = json.load(f)
except (OSError, json.JSONDecodeError) as e: except (OSError, json.JSONDecodeError) as e:
logger.warning(f"[Pages V3] Could not load schema for {app_id}: {e}", exc_info=True) logger.warning("Could not load starlark schema for app: %s", e)
# Load config from config.json if it exists # Load config from config.json if it exists — validate path stays within starlark_base
config = {} config = {}
config_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'config.json' config_file = (starlark_base / app_id / 'config.json').resolve()
if config_file.exists(): try:
config_file.relative_to(starlark_base)
except ValueError:
config_file = None
if config_file and config_file.exists():
try: try:
with open(config_file, 'r') as f: with open(config_file, 'r') as f:
config = json.load(f) config = json.load(f)
except (OSError, json.JSONDecodeError) as e: except (OSError, json.JSONDecodeError) as e:
logger.warning(f"[Pages V3] Could not load config for {app_id}: {e}", exc_info=True) logger.warning("Could not load starlark config for app: %s", e)
return render_template( return render_template(
'v3/partials/starlark_config.html', 'v3/partials/starlark_config.html',
@@ -528,5 +570,5 @@ def _load_starlark_config_partial(app_id):
) )
except Exception as e: except Exception as e:
logger.exception(f"[Pages V3] Error loading starlark config for {app_id}") logger.error("[Pages V3] Error loading starlark config for app", exc_info=True)
return f'<div class="text-red-500 p-4">Error loading starlark config: {str(e)}</div>', 500 return '<div class="text-red-500 p-4">Error loading starlark config; see logs for details</div>', 500

View File

@@ -1,4 +1,4 @@
/* global showNotification, updateSystemStats, htmx */ /* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
// LED Matrix v3 JavaScript // LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration // Additional helpers for HTMX and Alpine.js integration
@@ -51,7 +51,8 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
} }
}); });
// SSE reconnection helper // SSE reconnection helper — closes and reopens both SSE streams,
// reattaching the open/error handlers defined in base.html.
window.reconnectSSE = function() { window.reconnectSSE = function() {
if (window.statsSource) { if (window.statsSource) {
window.statsSource.close(); window.statsSource.close();
@@ -60,14 +61,18 @@ window.reconnectSSE = function() {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (typeof updateSystemStats === 'function') updateSystemStats(data); if (typeof updateSystemStats === 'function') updateSystemStats(data);
}; };
if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler);
if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler);
} }
if (window.displaySource) { if (window.displaySource) {
window.displaySource.close(); window.displaySource.close();
window.displaySource = new EventSource('/api/v3/stream/display'); window.displaySource = new EventSource('/api/v3/stream/display');
window.displaySource.onmessage = function() { window.displaySource.onmessage = function(event) {
// Handle display updates const data = JSON.parse(event.data);
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
}; };
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
} }
}; };

View File

@@ -51,8 +51,10 @@
sanitizeValue(value) { sanitizeValue(value) {
// Base implementation - widgets should override for specific needs // Base implementation - widgets should override for specific needs
if (typeof value === 'string') { if (typeof value === 'string') {
// Basic XSS prevention // Strip all HTML tags via the DOM parser to prevent XSS
return value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ''); const div = document.createElement('div');
div.textContent = value;
return div.textContent;
} }
return value; return value;
} }

View File

@@ -0,0 +1,783 @@
/**
* JsonFileManager — reusable JSON file management widget for LEDMatrix plugins.
*
* Usage via config_schema.json:
* "file_manager": {
* "type": "null",
* "title": "Data Files",
* "x-widget": "json-file-manager",
* "x-widget-config": {
* "actions": {
* "list": "list-files", // required
* "get": "get-file", // required for editing
* "save": "save-file", // required for editing
* "upload": "upload-file", // optional
* "delete": "delete-file", // optional
* "create": "create-file", // optional
* "toggle": "toggle-category" // optional
* },
* "upload_hint": "Hint text under the drop zone",
* "directory_label": "of_the_day/",
* "create_fields": [
* { "key": "category_name", "label": "Category Name",
* "placeholder": "my_words", "pattern": "^[a-z0-9_]+$",
* "hint": "Used as filename" },
* { "key": "display_name", "label": "Display Name",
* "placeholder": "My Words" }
* ],
* "toggle_key": "category_name"
* }
* }
*
* No CDN dependencies. Works on all modern browsers.
*/
(function () {
'use strict';
class JsonFileManager {
constructor(container, config, pluginId) {
// Prevent duplicate instances on the same container
if (container._jfmInstance) {
container._jfmInstance._destroy();
}
container._jfmInstance = this;
this.el = container;
this.pluginId = pluginId;
this.actions = config.actions || {};
this.uploadHint = config.upload_hint || '';
this.dirLabel = config.directory_label || '';
this.createFields = config.create_fields || [];
this.toggleKey = config.toggle_key || null;
// Unique prefix for all DOM IDs in this instance
this._uid = 'jfm_' + Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join('');
// Mutable state
this._editFile = null;
this._deleteFile = null;
this._keyHandler = this._onKey.bind(this);
this._inject();
this._bind();
this._loadList();
}
// ── Lifecycle ────────────────────────────────────────────────────────
_destroy() {
document.removeEventListener('keydown', this._keyHandler);
this.el._jfmInstance = null;
}
// ── DOM Injection ────────────────────────────────────────────────────
_inject() {
const u = this._uid;
const hasUpload = !!this.actions.upload;
const hasCreate = !!this.actions.create;
const hasDelete = !!this.actions.delete;
this.el.innerHTML = this._css(u) + `
<div id="${u}" class="jfm">
<div class="jfm-header">
<div class="jfm-header-left">
<span class="jfm-title">Data Files</span>
${this.dirLabel ? `<code class="jfm-dir">${this._esc(this.dirLabel)}</code>` : ''}
</div>
<div class="jfm-header-right">
${hasCreate ? `<button type="button" class="jfm-btn jfm-btn-primary jfm-btn-sm" data-jfm="open-create">+ New File</button>` : ''}
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="refresh" title="Refresh file list">&#8635;</button>
</div>
</div>
<div id="${u}-list" class="jfm-list">
<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>
</div>
${hasUpload ? `
<div class="jfm-upload-wrap">
<input type="file" accept=".json" id="${u}-fileinput" tabindex="-1">
<div class="jfm-dropzone" id="${u}-dropzone" data-jfm="open-picker" role="button" tabindex="0"
aria-label="Upload JSON file">
<span class="jfm-drop-icon">&#128193;</span>
<p class="jfm-drop-primary">Drop a JSON file here, or click to browse</p>
${this.uploadHint ? `<p class="jfm-drop-hint">${this._esc(this.uploadHint)}</p>` : ''}
</div>
</div>` : ''}
<!-- ── Edit modal ─────────────────────────────────────── -->
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal-box jfm-modal-wide">
<div class="jfm-modal-head">
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
<div class="jfm-modal-tools">
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">&times;</button>
</div>
</div>
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
<textarea id="${u}-editor" class="jfm-editor"
spellcheck="false" autocomplete="off"
autocorrect="off" autocapitalize="off"
aria-label="JSON editor"></textarea>
<div class="jfm-modal-foot">
<span id="${u}-charcount" class="jfm-stat"></span>
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="save" id="${u}-save-btn">Save</button>
</div>
</div>
</div>
<!-- ── Delete modal ───────────────────────────────────── -->
${hasDelete ? `
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span class="jfm-modal-title">Delete file</span>
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
<p>Delete <strong id="${u}-del-name"></strong>?</p>
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
</div>
<div class="jfm-modal-foot">
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
</div>
</div>
</div>` : ''}
<!-- ── Create modal ───────────────────────────────────── -->
${hasCreate ? `
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span class="jfm-modal-title">Create new file</span>
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
${this.createFields.map(f => `
<div class="jfm-field">
<label for="${u}-cf-${this._esc(f.key)}">${this._esc(f.label)}</label>
<input type="text" id="${u}-cf-${this._esc(f.key)}"
placeholder="${this._esc(f.placeholder || '')}"
${f.pattern ? `pattern="${this._esc(f.pattern)}"` : ''}>
${f.hint ? `<span class="jfm-hint">${this._esc(f.hint)}</span>` : ''}
</div>`).join('')}
</div>
<div class="jfm-modal-foot">
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-create">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="do-create" id="${u}-create-btn">Create</button>
</div>
</div>
</div>` : ''}
</div>`; // end #${u}
// Cache frequently-used elements
this._root = document.getElementById(u);
this._listEl = document.getElementById(`${u}-list`);
this._editorEl = document.getElementById(`${u}-editor`);
this._editModal = document.getElementById(`${u}-edit-modal`);
this._delModal = document.getElementById(`${u}-del-modal`);
this._createModal = document.getElementById(`${u}-create-modal`);
this._dropzone = document.getElementById(`${u}-dropzone`);
this._fileInput = document.getElementById(`${u}-fileinput`);
}
_css(u) {
return `<style>
#${u}{font-family:inherit;color:#111827;}
#${u} *{box-sizing:border-box;}
/* Header */
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
/* Buttons */
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
/* File list */
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
/* File cards */
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
#${u} .jfm-card.jfm-off{opacity:.6;}
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
/* Toggle */
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
/* Upload zone */
#${u} .jfm-upload-wrap{margin-top:.25rem;}
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
/* Modals */
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
#${u} .jfm-modal[hidden]{display:none;}
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
#${u} .jfm-modal-wide{max-width:880px;}
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
/* JSON editor */
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
#${u} .jfm-err-bar[hidden]{display:none;}
/* Create form */
#${u} .jfm-field{margin-bottom:.875rem;}
#${u} .jfm-field:last-child{margin-bottom:0;}
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
/* Spinner */
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
</style>`;
}
// ── Event Binding ────────────────────────────────────────────────────
_bind() {
// Delegated clicks on the widget root
this._root.addEventListener('click', this._onClick.bind(this));
this._root.addEventListener('change', this._onChange.bind(this));
// Drag-and-drop on the dropzone
if (this._dropzone) {
this._dropzone.addEventListener('dragover', e => {
e.preventDefault();
this._dropzone.classList.add('jfm-over');
});
this._dropzone.addEventListener('dragleave', () => {
this._dropzone.classList.remove('jfm-over');
});
this._dropzone.addEventListener('drop', e => {
e.preventDefault();
this._dropzone.classList.remove('jfm-over');
const file = e.dataTransfer?.files[0];
if (file) this._uploadFile(file);
});
// Keyboard activation of drop zone
this._dropzone.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._fileInput?.click();
}
});
}
// Modal backdrop clicks
[this._editModal, this._delModal, this._createModal].forEach(m => {
if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); });
});
// Editor: char count + Tab indent
if (this._editorEl) {
this._editorEl.addEventListener('input', () => this._updateStat());
this._editorEl.addEventListener('keydown', e => {
if (e.key === 'Tab') {
e.preventDefault();
const s = this._editorEl.selectionStart;
const end = this._editorEl.selectionEnd;
const v = this._editorEl.value;
this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end);
this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2;
this._updateStat();
}
});
}
// Global keyboard shortcuts
document.addEventListener('keydown', this._keyHandler);
}
_onKey(e) {
const editOpen = this._editModal && !this._editModal.hidden;
const delOpen = this._delModal && !this._delModal.hidden;
const createOpen = this._createModal && !this._createModal.hidden;
if (e.key === 'Escape') {
if (editOpen) { this._closeEdit(); return; }
if (delOpen) { this._closeDel(); return; }
if (createOpen) { this._closeCreate(); return; }
}
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
e.preventDefault();
this._doSave();
}
}
_onClick(e) {
const btn = e.target.closest('[data-jfm]');
if (!btn) return;
const action = btn.dataset.jfm;
switch (action) {
case 'refresh': this._loadList(); break;
case 'open-picker': this._fileInput?.click(); break;
case 'open-create': this._openCreate(); break;
case 'close-edit': this._closeEdit(); break;
case 'close-del': this._closeDel(); break;
case 'close-create': this._closeCreate(); break;
case 'fmt': this._formatJson(); break;
case 'validate': this._validateJson(); break;
case 'save': this._doSave(); break;
case 'confirm-del': this._doDelete(); break;
case 'do-create': this._doCreate(); break;
case 'edit-file': {
const card = btn.closest('[data-jfm-file]');
if (card) this._openEdit(card.dataset.jfmFile);
break;
}
case 'del-file': {
const card = btn.closest('[data-jfm-file]');
if (card) this._openDel(card.dataset.jfmFile);
break;
}
}
}
_onChange(e) {
// Toggle checkbox
if (e.target.classList.contains('jfm-toggle-cb')) {
const catName = e.target.dataset.cat;
const enabled = e.target.checked;
this._doToggle(catName, enabled, e.target);
}
// File input
if (e.target === this._fileInput) {
const file = e.target.files?.[0];
if (file) this._uploadFile(file);
e.target.value = '';
}
}
// ── API helper ───────────────────────────────────────────────────────
async _api(actionKey, params) {
const actionId = Object.prototype.hasOwnProperty.call(this.actions, actionKey) ? this.actions[actionKey] : undefined;
if (!actionId) throw new Error(`Action "${actionKey}" not configured`);
const body = { plugin_id: this.pluginId, action_id: actionId };
if (params !== undefined) body.params = params;
const r = await fetch('/api/v3/plugins/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!r.ok) throw new Error('Server error ' + r.status);
const ct = r.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
const txt = await r.text();
throw new Error('Unexpected response: ' + txt.slice(0, 120));
}
return r.json();
}
// ── File List ────────────────────────────────────────────────────────
async _loadList() {
this._listEl.innerHTML = `<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>`;
try {
const data = await this._api('list');
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
this._renderList(data.files || []);
} catch (err) {
this._listEl.innerHTML = `
<div class="jfm-empty">
<div class="jfm-empty-icon">&#9888;</div>
<p class="jfm-empty-title">Failed to load files</p>
<p class="jfm-empty-sub">${this._esc(err.message)}</p>
</div>`;
}
}
_renderList(files) {
if (!files.length) {
this._listEl.innerHTML = `
<div class="jfm-empty">
<div class="jfm-empty-icon">&#128193;</div>
<p class="jfm-empty-title">No files yet</p>
<p class="jfm-empty-sub">Upload or create a JSON file to get started</p>
</div>`;
return;
}
this._listEl.innerHTML = files.map(f => this._card(f)).join('');
}
_card(f) {
const enabled = f.enabled !== false;
const displayName = this._esc(f.display_name || f.filename);
const filename = this._esc(f.filename);
const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : '';
const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]);
const hasEdit = !!this.actions.get && !!this.actions.save;
const hasDelete = !!this.actions.delete;
return `
<div class="jfm-card${enabled ? '' : ' jfm-off'}" data-jfm-file="${filename}">
<div class="jfm-card-top">
<span class="jfm-card-name" title="${filename}">${displayName}</span>
${showToggle ? `
<label class="jfm-toggle" title="${enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}">
<input type="checkbox" class="jfm-toggle-cb" data-cat="${catName}" ${enabled ? 'checked' : ''}>
<span>${enabled ? 'On' : 'Off'}</span>
</label>` : ''}
</div>
<div class="jfm-card-meta">
<span>&#128196; ${filename}</span>
<span>&#128202; ${f.entry_count ?? 0} entries &middot; ${this._fmtSize(f.size || 0)}</span>
<span>&#128337; ${this._fmtDate(f.modified)}</span>
</div>
<div class="jfm-card-actions">
${hasEdit ? `<button type="button" class="jfm-btn jfm-btn-sm" data-jfm="edit-file">&#9998; Edit</button>` : ''}
${hasDelete ? `<button type="button" class="jfm-btn jfm-btn-danger jfm-btn-sm jfm-del" data-jfm="del-file" title="Delete file">&#128465;</button>` : ''}
</div>
</div>`;
}
// ── Edit flow ────────────────────────────────────────────────────────
async _openEdit(filename) {
this._editFile = filename;
document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`;
this._clearErr();
this._editorEl.value = 'Loading…';
this._updateStat();
this._editModal.hidden = false;
try {
const data = await this._api('get', { filename });
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
this._editorEl.value = JSON.stringify(data.content, null, 2);
this._updateStat();
this._editorEl.focus();
this._editorEl.setSelectionRange(0, 0);
this._editorEl.scrollTop = 0;
} catch (err) {
this._showErr('Failed to load file: ' + err.message);
this._editorEl.value = '';
}
}
_closeEdit() {
if (this._editModal) this._editModal.hidden = true;
this._editFile = null;
this._clearErr();
}
_formatJson() {
try {
const parsed = JSON.parse(this._editorEl.value);
this._editorEl.value = JSON.stringify(parsed, null, 2);
this._updateStat();
this._clearErr();
} catch (err) {
this._showErr('Invalid JSON — ' + err.message);
}
}
_validateJson() {
try {
const parsed = JSON.parse(this._editorEl.value);
const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?';
this._clearErr();
this._notify(`Valid JSON — ${n} top-level keys`, 'success');
} catch (err) {
this._showErr('Invalid JSON — ' + err.message);
}
}
async _doSave() {
if (!this._editFile) return;
let contentStr;
try {
const parsed = JSON.parse(this._editorEl.value);
contentStr = JSON.stringify(parsed, null, 2);
} catch (err) {
this._showErr('Cannot save — fix JSON first: ' + err.message);
return;
}
const btn = document.getElementById(`${this._uid}-save-btn`);
this._busy(btn, 'Saving…');
try {
const data = await this._api('save', { filename: this._editFile, content: contentStr });
if (data.status !== 'success') throw new Error(data.message || 'Save failed');
this._notify('File saved', 'success');
this._closeEdit();
this._loadList();
} catch (err) {
this._showErr('Save failed: ' + err.message);
} finally {
this._idle(btn, 'Save');
}
}
// ── Delete flow ──────────────────────────────────────────────────────
_openDel(filename) {
this._deleteFile = filename;
const el = document.getElementById(`${this._uid}-del-name`);
if (el) el.textContent = filename;
if (this._delModal) this._delModal.hidden = false;
}
_closeDel() {
if (this._delModal) this._delModal.hidden = true;
this._deleteFile = null;
}
async _doDelete() {
if (!this._deleteFile) return;
const btn = document.getElementById(`${this._uid}-del-btn`);
this._busy(btn, 'Deleting…');
try {
const data = await this._api('delete', { filename: this._deleteFile });
if (data.status !== 'success') throw new Error(data.message || 'Delete failed');
this._notify('File deleted', 'success');
this._closeDel();
this._loadList();
} catch (err) {
this._notify('Delete failed: ' + err.message, 'error');
} finally {
this._idle(btn, 'Delete');
}
}
// ── Create flow ──────────────────────────────────────────────────────
_openCreate() {
if (!this._createModal) return;
this.createFields.forEach(f => {
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
if (el) el.value = '';
});
this._createModal.hidden = false;
const first = this.createFields[0];
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
}
_closeCreate() {
if (this._createModal) this._createModal.hidden = true;
}
async _doCreate() {
const params = {};
for (const f of this.createFields) {
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
const val = (el?.value || '').trim();
// display_name may be blank — auto-derived from category_name below
if (!val && f.key !== 'display_name') {
this._notify(`"${f.label}" is required`, 'error');
el?.focus();
return;
}
if (f.pattern && val && el && el.validity.patternMismatch) {
this._notify(`"${f.label}" format is invalid`, 'error');
el?.focus();
return;
}
if (val) params[f.key] = val;
}
// Auto-derive display_name from category_name when left blank
if (!params.display_name && params.category_name) {
params.display_name = params.category_name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
const btn = document.getElementById(`${this._uid}-create-btn`);
this._busy(btn, 'Creating…');
try {
const data = await this._api('create', params);
if (data.status !== 'success') throw new Error(data.message || 'Create failed');
this._notify('File created', 'success');
this._closeCreate();
this._loadList();
} catch (err) {
this._notify('Create failed: ' + err.message, 'error');
} finally {
this._idle(btn, 'Create');
}
}
// ── Upload ───────────────────────────────────────────────────────────
async _uploadFile(file) {
if (!file.name.endsWith('.json')) {
this._notify('Please select a .json file', 'error');
return;
}
let content;
try {
content = await file.text();
JSON.parse(content); // client-side validation
} catch (err) {
this._notify('Invalid JSON: ' + err.message, 'error');
return;
}
if (this._dropzone) this._dropzone.style.opacity = '.5';
try {
const data = await this._api('upload', { filename: file.name, content });
if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
this._notify(`"${file.name}" uploaded`, 'success');
this._loadList();
} catch (err) {
this._notify('Upload failed: ' + err.message, 'error');
} finally {
if (this._dropzone) this._dropzone.style.opacity = '';
}
}
// ── Toggle ───────────────────────────────────────────────────────────
async _doToggle(catName, enabled, checkbox) {
checkbox.disabled = true;
try {
const params = { enabled };
if (this.toggleKey) params[this.toggleKey] = catName;
const data = await this._api('toggle', params);
if (data.status !== 'success') throw new Error(data.message || 'Toggle failed');
this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success');
this._loadList();
} catch (err) {
this._notify('Toggle failed: ' + err.message, 'error');
checkbox.checked = !enabled; // revert
checkbox.disabled = false;
}
}
// ── Helpers ──────────────────────────────────────────────────────────
_closeAll() {
this._closeEdit();
this._closeDel();
this._closeCreate();
}
_updateStat() {
const v = this._editorEl?.value || '';
const lines = v ? v.split('\n').length : 0;
const el = document.getElementById(`${this._uid}-charcount`);
if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`;
}
_showErr(msg) {
const el = document.getElementById(`${this._uid}-edit-err`);
if (el) { el.textContent = msg; el.hidden = false; }
}
_clearErr() {
const el = document.getElementById(`${this._uid}-edit-err`);
if (el) { el.textContent = ''; el.hidden = true; }
}
_notify(msg, type) {
if (typeof window.showNotification === 'function') {
window.showNotification(msg, type || 'info');
} else {
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
}
}
_busy(btn, label) {
if (!btn) return;
btn._jfmOrigText = btn.textContent;
btn.disabled = true;
btn.textContent = '';
const spin = document.createElement('span');
spin.className = 'jfm-spin';
btn.appendChild(spin);
btn.appendChild(document.createTextNode(' ' + label));
}
_idle(btn, label) {
if (!btn) return;
btn.disabled = false;
btn.textContent = btn._jfmOrigText !== undefined ? btn._jfmOrigText : label;
delete btn._jfmOrigText;
}
_esc(str) {
const d = document.createElement('div');
d.textContent = String(str ?? '');
return d.innerHTML;
}
_fmtSize(bytes) {
if (!bytes) return '0 B';
const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2);
const unit = ['B', 'KB', 'MB'][i];
const val = bytes / Math.pow(1024, i);
return (i ? val.toFixed(1) : val) + ' ' + unit;
}
_fmtDate(str) {
if (!str) return '—';
try {
return new Date(str).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric'
});
} catch { return str; }
}
}
// ── Widget registry integration ──────────────────────────────────────────
window.JsonFileManager = JsonFileManager;
if (typeof window.LEDMatrixWidgets !== 'undefined') {
window.LEDMatrixWidgets.register('json-file-manager', {
name: 'JSON File Manager',
version: '1.0.0',
render(container, config, _value, options) {
new JsonFileManager(container, config || {}, options?.pluginId || '');
},
getValue() { return null; },
setValue() {}
});
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
} else {
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
}
})();

View File

@@ -1442,9 +1442,14 @@ function renderInstalledPlugins(plugins) {
return; return;
} }
// Helper function to escape attributes for use in HTML // Helper function to escape values for use in HTML attributes
const escapeAttr = (text) => { const escapeAttr = (text) => {
return (text || '').replace(/'/g, "\\'").replace(/"/g, '&quot;'); return (text || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}; };
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping) // Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
@@ -3441,6 +3446,28 @@ function generateFieldHtml(key, prop, value, prefix = '') {
html += `<option value="${option}" ${selected}>${option}</option>`; html += `<option value="${option}" ${selected}>${option}</option>`;
}); });
html += `</select>`; html += `</select>`;
} else if (prop['x-widget'] === 'json-file-manager') {
// Reusable JSON file manager widget (no CDN, keyboard shortcuts, configurable actions)
const widgetConfig = prop['x-widget-config'] || {};
const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
const safeFieldId = (fullKey || 'file_manager').replace(/[^a-zA-Z0-9_-]/g, '_');
html += `<div id="${safeFieldId}_jfm_mount"></div>`;
setTimeout(() => {
const mount = document.getElementById(`${safeFieldId}_jfm_mount`);
if (!mount) return;
// Destroy the previous instance for this mount only — leave other instances intact
window.__jfmInstances = window.__jfmInstances || {};
const prev = window.__jfmInstances[safeFieldId];
if (prev?._destroy) prev._destroy();
if (typeof JsonFileManager !== 'undefined') {
window.__jfmInstances[safeFieldId] = new JsonFileManager(mount, widgetConfig, pluginId);
} else {
window.__jfmInstances[safeFieldId] = null;
mount.innerHTML = '<p style="color:#dc2626;font-size:.875rem;">json-file-manager widget not loaded. Check base.html includes json-file-manager.js.</p>';
}
}, 150);
} else if (prop['x-widget'] === 'custom-html') { } else if (prop['x-widget'] === 'custom-html') {
// Custom HTML widget - load HTML from plugin directory // Custom HTML widget - load HTML from plugin directory
const htmlFile = prop['x-html-file']; const htmlFile = prop['x-html-file'];
@@ -4507,6 +4534,8 @@ function syncFormToJson() {
// Deep merge with existing config to preserve nested structures // Deep merge with existing config to preserve nested structures
function deepMerge(target, source) { function deepMerge(target, source) {
for (const key in source) { for (const key in source) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) { if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
target[key] = {}; target[key] = {};
@@ -7473,17 +7502,28 @@ setTimeout(function() {
console.log('installed-plugins-grid not found yet, will retry via event listeners'); console.log('installed-plugins-grid not found yet, will retry via event listeners');
} }
// Also try to attach install button handler after a delay (fallback) // Also try to attach install button handler after a delay (fallback).
// Only run if the install button element is already in the DOM (i.e. the
// plugins partial has been loaded); otherwise the htmx:afterSettle listener
// below handles it when the tab is first visited.
setTimeout(() => { setTimeout(() => {
if (typeof window.attachInstallButtonHandler === 'function') { if (typeof window.attachInstallButtonHandler === 'function' &&
console.log('[FALLBACK] Attempting to attach install button handler...'); document.getElementById('install-plugin-from-url')) {
window.attachInstallButtonHandler(); window.attachInstallButtonHandler();
} else {
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
} }
}, 500); }, 500);
}, 200); }, 200);
// Re-run install button wiring after HTMX settles the plugins tab content.
// Guard with element check so it only fires when the plugins partial is in the DOM,
// preventing spurious warnings on other tab loads.
document.addEventListener('htmx:afterSettle', function() {
if (document.getElementById('install-plugin-from-url') &&
typeof window.attachInstallButtonHandler === 'function') {
window.attachInstallButtonHandler();
}
});
// ─── Starlark Apps Integration ────────────────────────────────────────────── // ─── Starlark Apps Integration ──────────────────────────────────────────────
(function() { (function() {

View File

@@ -136,6 +136,7 @@
setTimeout(function() { setTimeout(function() {
if (typeof htmx !== 'undefined') { if (typeof htmx !== 'undefined') {
console.log('HTMX loaded from fallback'); console.log('HTMX loaded from fallback');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads // Load extensions after core loads
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js'); loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js'); loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
@@ -152,6 +153,7 @@
} }
} else { } else {
console.log('HTMX loaded successfully'); console.log('HTMX loaded successfully');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads // Load extensions after core loads
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js'); loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js'); loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
@@ -349,6 +351,20 @@
} }
} }
}); });
// Set data-loaded on tab containers after HTMX settles their content,
// preventing repeated re-fetches on every tab switch.
// Scoped to elements with hx-trigger="revealed" (tab containers only) so
// modals and plugin config panels that legitimately reload are unaffected.
document.body.addEventListener('htmx:afterSettle', function(event) {
if (event.detail && event.detail.target) {
var target = event.detail.target;
var trigger = target.getAttribute('hx-trigger') || '';
if (trigger.includes('revealed')) {
target.setAttribute('data-loaded', 'true');
}
}
});
} else { } else {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupScriptExecution); document.addEventListener('DOMContentLoaded', setupScriptExecution);
@@ -411,6 +427,9 @@
.then(html => { .then(html => {
clearTimeout(timeout); clearTimeout(timeout);
content.innerHTML = html; content.innerHTML = html;
if (typeof htmx !== 'undefined') {
htmx.process(content);
}
// Trigger full initialization chain // Trigger full initialization chain
if (window.pluginManager) { if (window.pluginManager) {
window.pluginManager.initialized = false; window.pluginManager.initialized = false;
@@ -430,7 +449,7 @@
} }
// Fallback if HTMX doesn't load within 5 seconds // Fallback if HTMX doesn't load within 5 seconds
setTimeout(() => { var _pluginsFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') { if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins'); console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
// Load plugins tab content directly regardless of active tab, // Load plugins tab content directly regardless of active tab,
@@ -438,6 +457,7 @@
loadPluginsDirect(); loadPluginsDirect();
} }
}, 5000); }, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
</script> </script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes --> <!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script> <script>
@@ -1030,6 +1050,9 @@
.then(html => { .then(html => {
overviewContent.innerHTML = html; overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true'); overviewContent.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') {
htmx.process(overviewContent);
}
// Re-initialize Alpine.js for the new content // Re-initialize Alpine.js for the new content
if (window.Alpine) { if (window.Alpine) {
window.Alpine.initTree(overviewContent); window.Alpine.initTree(overviewContent);
@@ -1058,7 +1081,7 @@
}); });
// Also try direct load if HTMX doesn't load within 5 seconds // Also try direct load if HTMX doesn't load within 5 seconds
setTimeout(() => { var _overviewFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') { if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content'); console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
const appElement = document.querySelector('[x-data="app()"]'); const appElement = document.querySelector('[x-data="app()"]');
@@ -1070,6 +1093,7 @@
} }
} }
}, 5000); }, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
</script> </script>
<!-- General tab --> <!-- General tab -->
@@ -1346,34 +1370,64 @@
<!-- SSE connection for real-time updates --> <!-- SSE connection for real-time updates -->
<script> <script>
// Connect to SSE streams // Assign to window so reconnectSSE() in app.js can reach them.
const statsSource = new EventSource('/api/v3/stream/stats'); window.statsSource = new EventSource('/api/v3/stream/stats');
const displaySource = new EventSource('/api/v3/stream/display'); window.displaySource = new EventSource('/api/v3/stream/display');
statsSource.onmessage = function(event) { window.statsSource.onmessage = function(event) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
updateSystemStats(data); updateSystemStats(data);
}; };
displaySource.onmessage = function(event) { window.displaySource.onmessage = function(event) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
updateDisplayPreview(data); updateDisplayPreview(data);
}; };
// Connection status function _setConnectionStatus(connected, reconnecting) {
statsSource.addEventListener('open', function() { const el = document.getElementById('connection-status');
document.getElementById('connection-status').innerHTML = ` if (!el) return;
<div class="w-2 h-2 bg-green-500 rounded-full"></div> if (connected) {
<span class="text-gray-600">Connected</span> 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>
`;
}
}
statsSource.addEventListener('error', function() { var _statsErrorCount = 0;
document.getElementById('connection-status').innerHTML = `
<div class="w-2 h-2 bg-red-500 rounded-full"></div> // Named on window so reconnectSSE() in app.js can reattach them after
<span class="text-gray-600">Disconnected</span> // 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);
function updateSystemStats(data) { function updateSystemStats(data) {
// Update CPU in header // Update CPU in header
@@ -1816,13 +1870,18 @@
htmx.trigger(contentEl, 'revealed'); htmx.trigger(contentEl, 'revealed');
} }
} else { } else {
// HTMX not available, use direct fetch // HTMX is still loading asynchronously — retry when it signals ready,
console.warn('HTMX not available, using direct fetch for tab:', tab); // or fall back to direct fetch if it fails to load entirely.
if (tab === 'overview' && typeof loadOverviewDirect === 'function') { const self = this;
loadOverviewDirect(); function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); }
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') { function onFailed() {
loadWifiDirect(); window.removeEventListener('htmx:ready', onReady);
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
} }
window.addEventListener('htmx:ready', onReady, { once: true });
window.addEventListener('htmx-load-failed', onFailed, { once: true });
} }
}, },
@@ -4566,6 +4625,9 @@
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
<!-- Legacy plugins_manager.js (for backward compatibility during migration) --> <!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script> <script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>

View File

@@ -73,7 +73,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}' hx-vals='{"action": "start_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i> <i class="fas fa-play mr-2"></i>
Start Display Start Display
@@ -82,7 +82,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}' hx-vals='{"action": "stop_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i> <i class="fas fa-stop mr-2"></i>
Stop Display Stop Display
@@ -91,7 +91,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "git_pull"}' hx-vals='{"action": "git_pull"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i> <i class="fas fa-download mr-2"></i>
Update Code Update Code
@@ -101,7 +101,7 @@
hx-vals='{"action": "reboot_system"}' hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?" hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Reboot System Reboot System

View File

@@ -151,7 +151,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}' hx-vals='{"action": "start_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i> <i class="fas fa-play mr-2"></i>
Start Display Start Display
@@ -160,7 +160,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}' hx-vals='{"action": "stop_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i> <i class="fas fa-stop mr-2"></i>
Stop Display Stop Display
@@ -170,7 +170,7 @@
hx-vals='{"action": "git_pull"}' hx-vals='{"action": "git_pull"}'
hx-confirm="This will stash any local changes and update the code. Continue?" hx-confirm="This will stash any local changes and update the code. Continue?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i> <i class="fas fa-download mr-2"></i>
Update Code Update Code
@@ -180,7 +180,7 @@
hx-vals='{"action": "reboot_system"}' hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?" hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Reboot System Reboot System
@@ -190,7 +190,7 @@
hx-vals='{"action": "shutdown_system"}' hx-vals='{"action": "shutdown_system"}'
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi." hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System shutting down...', event.detail.xhr.responseJSON.status || 'info'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Shutdown System Shutdown System
@@ -199,7 +199,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_display_service"}' hx-vals='{"action": "restart_display_service"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display service restarted', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i> <i class="fas fa-redo mr-2"></i>
Restart Display Service Restart Display Service
@@ -208,7 +208,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_web_service"}' hx-vals='{"action": "restart_web_service"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Web service restarted', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i> <i class="fas fa-redo mr-2"></i>
Restart Web Service Restart Web Service