From a8b3e86775e69aa74ac8ed4ebc219ebdb9acbd3b Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:36:53 -0400 Subject: [PATCH] refactor(web): delete dead routes, JS files and duplicate definitions (#609) * refactor(web): drop validators nothing calls escape_html, validate_image_url, validate_font_awesome_class, validate_mime_type, validate_numeric_range, validate_string_length and sanitize_plugin_config had no callers outside their own tests. Only validate_file_upload (fonts upload) is imported by the web interface. dedup_unique_arrays is kept: its one caller in save_plugin_config was removed by the unrelated sync PR (#330), which looks accidental. Co-Authored-By: Claude Opus 5.5 * refactor(api): remove the music-auth and of-the-day JSON routes POST /plugins/authenticate/spotify and /plugins/authenticate/ytm had no caller but their tests: the music plugin authenticates through its web_ui_actions (authenticate_spotify.py / authenticate_ytm.py) via /plugins/action. POST /plugins/of-the-day/json/upload and /json/delete looked the plugin up by the id ledmatrix-of-the-day (its manifest id is of-the-day), were reachable only from a file_type "json" upload field that no schema declares, and put the plugin directory on sys.path per request to import scripts.update_config. of-the-day manages its files through plugin-file-manager and its own web_ui_actions. The of-the-day branch of GET /plugins/config stays: it matches the real manifest id and still merges the on-disk category files into the form. Co-Authored-By: Claude Opus 5.5 * refactor(api): read managers only from the blueprints api_v3/__init__.py and pages_v3.py declared module globals (plugin_store_manager, saved_repositories_manager, schema_manager, operation_queue, plugin_state_manager, operation_history, sync_manager, config_manager, plugin_manager) that nothing assigns: app.py sets the managers as attributes on the Blueprint objects, and every route reads them there. The one reader, backup restore's fallback to the module plugin_store_manager, could only ever fall back to None. _ensure_cache_manager() built a second CacheManager in the web process instead of using the one app.py puts on api_v3. The display routes now read api_v3.cache_manager, creating it on the blueprint only when nothing set it (the same None handling as the /cache routes). Co-Authored-By: Claude Opus 5.5 * chore(web): drop run.sh and the unused log_config_change web_interface/run.sh was referenced only by web_interface/README.md; the service starts the UI through scripts/utils/start_web_conditionally.py and the README already documents `python3 web_interface/start.py`. log_config_change() in web_interface/logging_config.py was never called. Co-Authored-By: Claude Opus 5.5 * refactor(web): delete unreferenced store_manager.js, diff_viewer.js, htmx-sse.js - js/plugins/store_manager.js (window.PluginStoreManager) and js/config/diff_viewer.js (window.ConfigDiffViewer) were loaded on every page but nothing reads either global. - htmx-sse.js (plus its CDN fallback) was loaded after HTMX, but no template or plugin page uses sse-connect / hx-ext="sse": the live streams run through LEDStreams in app-shell.js. js/plugins/state_manager.js stays: install_manager.js's updateAll() reads and refreshes window.PluginStateManager. Co-Authored-By: Claude Opus 5.5 * refactor(web): remove app.js helpers nothing calls - hexToRgb, rgbToHex, validateForm, uploadFont and switchTab (whose 'switch-tab' event had no listener) have no caller in the templates, static JS or the plugin monorepo. - installPlugin: plugins_manager.js (loaded last) assigns window.installPlugin, and its own store cards are the only callers. - The showNotification fallback could never install: app-shell.js is deferred ahead of app.js and defines the same fallback at top level. - performanceMonitor only logged with ?debug=perf and read an unset this.measures; the marks it took on every load had no reader. Co-Authored-By: Claude Opus 5.5 * refactor(web): drop app-shell.js refreshPlugin A top-level function in app-shell.js, so a window global, but nothing calls it (no inline handler, no window lookup, no string-built name). The other plugin actions in that block stay. updatePlugin is the live window.updatePlugin: plugins_manager.js only installs its own copy when none exists. uninstallPlugin/pollUninstallOperation, updateAllPlugins, executePluginAction and toggleNestedSection are replaced by later deferred scripts, but a click that lands while those scripts are still downloading reaches the app-shell copies, so removing them is not a pure no-op. Co-Authored-By: Claude Opus 5.5 * refactor(web): remove definitions plugins_manager.js always overrides All of these are replaced before anything can call them, checked against the load order in base.html and the live window.* values: - openOnDemandModal/requestOnDemandStop stubs: the IIFE later in the same script assigns the real functions synchronously. - updatePlugin and uninstallPlugin stubs (`window.X || stub`): app-shell.js already defined both, so the fallback never installed. Same for the later updatePlugin override, gated on the live function containing '[UPDATE]', which app-shell.js's never does. - The first addArrayObjectItem/removeArrayObjectItem: reassigned by the top-level copies after the IIFE. - The first `function formatDate` in the IIFE: a later declaration of the same name in the same scope wins. - deleteUploadedImage, getCurrentImages, showUploadProgress, formatFileSize and getScheduleSummary: character-for-character copies of js/widgets/file-upload.js, which stays the owner. - `typeof X === 'undefined'` fallbacks and `typeof X !== 'undefined'` re-exports after the IIFE: always false, or a self-assignment. Co-Authored-By: Claude Opus 5.5 * refactor(web): render the shell directly and delete index.html index.html extended base.html with {% block content %}, but base.html defines no blocks, so none of index.html ever rendered: rendering both with jinja2 gives byte-identical output. index() still loaded the config, read config.json and config_secrets.json raw and json.dumps'd them on every page load for variables base.html never reads, and flashed errors that base.html never shows. It now renders base.html with no context. Co-Authored-By: Claude Opus 5.5 * fix(web): stop htmx-config.js replacing console.error and console.warn It swapped both globals for filters that dropped any error mentioning insertBefore / "Cannot read properties of null" when "htmx" appeared in the message or stack, and a list of Permissions-Policy warnings. That hid real errors from every script on the page, and made every logged error and warning report htmx-config.js as its source. The beforeSwap target validation above it, which prevents the insertBefore errors in the first place, stays. Co-Authored-By: Claude Opus 5.5 * chore(web): quiet the widget load announcements and debug logs About 30 lines hit the console on every page load: one "... widget registered" per widget file, one "[WidgetRegistry] Registered widget: X" per registration, plus the registry, base widget and plugin loader announcing themselves. The load-time announcements are removed; the per-call ones (registry register, plugin widget loads, "Render called") now go through the page's debugLog switch (localStorage.pluginDebug), guarded because the widgets also load in node tests without it. fonts.html and wifi.html debug logging goes through debugLog as well. Co-Authored-By: Claude Opus 5.5 * docs(api): drop the removed music-auth and of-the-day JSON routes Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/REST_API_REFERENCE.md | 55 +-- src/web_interface/validators.py | 195 -------- test/fixtures/api_v3_url_map.json | 32 -- test/test_api_v3_lazy_plugin_discovery.py | 9 +- test/test_api_v3_music_auth_endpoints.py | 302 ------------ test/test_api_v3_on_demand_restart.py | 10 +- test/test_api_v3_optional_body.py | 10 - test/test_path_traversal_guards.py | 51 -- test/test_web_api.py | 10 +- test/test_web_display_state_freshness.py | 2 +- .../test_api_v3_backup_restore.py | 3 +- test/web_interface/test_validators.py | 249 +--------- web_interface/README.md | 7 - web_interface/blueprints/api_v3/__init__.py | 25 +- web_interface/blueprints/api_v3/backup.py | 5 +- web_interface/blueprints/api_v3/display.py | 23 +- web_interface/blueprints/api_v3/plugins.py | 395 +-------------- web_interface/blueprints/pages_v3.py | 43 +- web_interface/logging_config.py | 26 - web_interface/run.sh | 17 - web_interface/static/v3/app.js | 188 +------ web_interface/static/v3/js/app-shell.js | 17 - .../static/v3/js/config/diff_viewer.js | 300 ----------- web_interface/static/v3/js/htmx-config.js | 61 --- web_interface/static/v3/js/htmx-sse.js | 356 -------------- .../static/v3/js/plugins/store_manager.js | 101 ---- .../static/v3/js/widgets/array-table.js | 4 +- .../static/v3/js/widgets/base-widget.js | 2 - .../static/v3/js/widgets/checkbox-group.js | 4 +- .../static/v3/js/widgets/color-picker.js | 2 - .../static/v3/js/widgets/custom-feeds.js | 4 +- .../static/v3/js/widgets/date-picker.js | 2 - .../static/v3/js/widgets/day-selector.js | 2 - .../static/v3/js/widgets/email-input.js | 2 - .../v3/js/widgets/example-color-picker.js | 2 - .../v3/js/widgets/file-upload-single.js | 2 - .../static/v3/js/widgets/file-upload.js | 4 +- .../static/v3/js/widgets/font-selector.js | 2 - .../v3/js/widgets/google-calendar-picker.js | 2 - .../static/v3/js/widgets/json-file-manager.js | 3 - .../static/v3/js/widgets/notification.js | 2 - .../static/v3/js/widgets/number-input.js | 2 - .../static/v3/js/widgets/password-input.js | 2 - .../v3/js/widgets/plugin-file-manager.js | 2 - .../static/v3/js/widgets/plugin-loader.js | 6 +- .../static/v3/js/widgets/radio-group.js | 2 - .../static/v3/js/widgets/registry.js | 4 +- .../static/v3/js/widgets/select-dropdown.js | 2 - web_interface/static/v3/js/widgets/slider.js | 2 - .../static/v3/js/widgets/style-editor.js | 2 - .../static/v3/js/widgets/text-input.js | 2 - .../static/v3/js/widgets/textarea.js | 2 - .../static/v3/js/widgets/time-picker.js | 2 - .../static/v3/js/widgets/time-range.js | 2 - .../static/v3/js/widgets/timezone-selector.js | 2 - .../static/v3/js/widgets/toggle-switch.js | 2 - .../static/v3/js/widgets/url-input.js | 2 - web_interface/static/v3/plugins_manager.js | 465 ------------------ web_interface/templates/v3/base.html | 9 +- web_interface/templates/v3/index.html | 161 ------ .../templates/v3/partials/fonts.html | 14 +- web_interface/templates/v3/partials/wifi.html | 4 +- 62 files changed, 73 insertions(+), 3149 deletions(-) delete mode 100644 test/test_api_v3_music_auth_endpoints.py delete mode 100755 web_interface/run.sh delete mode 100644 web_interface/static/v3/js/config/diff_viewer.js delete mode 100644 web_interface/static/v3/js/htmx-sse.js delete mode 100644 web_interface/static/v3/js/plugins/store_manager.js delete mode 100644 web_interface/templates/v3/index.html diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 4f36386a..26d2bb4d 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -1117,39 +1117,6 @@ List uploaded images for a plugin. } ``` -### Authenticate Spotify - -**POST** `/api/v3/plugins/authenticate/spotify` - -Spotify OAuth for the music plugin (`ledmatrix-music`; the plugin is fixed, -not taken from the body). Two steps: call with an empty body to get the -authorization URL, then call again with the URL Spotify redirected to. - -**Request Body** (step 2): -```json -{ - "redirect_url": "http://127.0.0.1:8888/callback?code=..." -} -``` - -**Response** (step 1, fields at the top level): -```json -{ - "status": "success", - "message": "Authorization URL generated", - "auth_url": "https://accounts.spotify.com/authorize?..." -} -``` - -Step 2 returns `status`, `message` and the script's `output`. - -### Authenticate YouTube Music - -**POST** `/api/v3/plugins/authenticate/ytm` - -Run the music plugin's YouTube Music authentication script. No body. Returns -`status`, `message` and the script's `output`. - ### Upload Calendar Credentials **POST** `/api/v3/plugins/calendar/upload-credentials` @@ -1967,7 +1934,10 @@ restarted to pick up changes). See ## Plugin-specific endpoints -A handful of endpoints belong to individual plugins. +A handful of endpoints belong to individual plugins. The music plugin's +Spotify and YouTube Music sign-in and the Of-The-Day data files go through the +plugin's own web UI actions ([Execute Plugin Action](#execute-plugin-action)) +rather than dedicated routes. ### Calendar @@ -1977,23 +1947,6 @@ List the calendars on the authenticated Google account. Used by the calendar plugin's config UI. Returns `calendars` at the top level. The upload and authenticate endpoints are under [Plugins](#upload-calendar-credentials). -### Of The Day - -**POST** `/api/v3/plugins/of-the-day/json/upload` - -Upload JSON data files (multipart field `files`) as Of-The-Day categories. -Returns `uploaded_files` and `total_files` at the top level. - -**POST** `/api/v3/plugins/of-the-day/json/delete` - -Delete an uploaded data file. - -```json -{ - "file_id": "category_name" -} -``` - ### Plugin Static Assets **GET** `/api/v3/plugins//static/` diff --git a/src/web_interface/validators.py b/src/web_interface/validators.py index fa1772ef..0a7ee6ef 100644 --- a/src/web_interface/validators.py +++ b/src/web_interface/validators.py @@ -1,94 +1,10 @@ """ Input validation utilities for the web interface. -Provides validation functions for user inputs to prevent XSS, invalid data, and security issues. """ -import re from typing import Optional, Tuple, List -from urllib.parse import urlparse from pathlib import Path -def escape_html(text: str) -> str: - """Escape HTML entities in text to prevent XSS.""" - if not isinstance(text, str): - text = str(text) - # Use basic HTML entity escaping - text = text.replace('&', '&') - text = text.replace('<', '<') - text = text.replace('>', '>') - text = text.replace('"', '"') - text = text.replace("'", ''') - return text - - -def validate_image_url(url: str) -> Tuple[bool, Optional[str]]: - """ - Validate and sanitize image URLs to prevent XSS and protocol injection. - - Returns: - Tuple of (is_valid, error_message) - """ - if not url or not isinstance(url, str): - return False, "URL must be a non-empty string" - - url_lower = url.lower().strip() - - # Reject dangerous protocols - dangerous_protocols = ['javascript:', 'data:', 'vbscript:', 'file:'] - for protocol in dangerous_protocols: - if url_lower.startswith(protocol): - return False, f"Dangerous protocol '{protocol}' not allowed" - - # Reject event handlers - if any(handler in url_lower for handler in ['onerror=', 'onload=', 'onclick=']): - return False, "Event handlers not allowed in URLs" - - # Reject directory traversal anywhere, not only in relative paths: - # http://host/../secret is as much a traversal attempt as /../secret. - if '..' in url: - return False, "Invalid path: directory traversal not allowed" - - # Allow relative paths starting with / - if url.startswith('/'): - # // would be a protocol-relative URL, not a local path - if url.startswith('//'): - return False, "Invalid relative path" - return True, None - - # Validate absolute URLs - try: - parsed = urlparse(url) - allowed_protocols = ['http', 'https'] - if parsed.scheme not in allowed_protocols: - return False, "Only http:// and https:// protocols are allowed" - return True, None - except Exception as e: - return False, f"Invalid URL format: {str(e)}" - - -def validate_font_awesome_class(class_name: str) -> Tuple[bool, Optional[str]]: - """ - Validate Font Awesome class names to prevent XSS. - - Returns: - Tuple of (is_valid, error_message) - """ - if not isinstance(class_name, str): - return False, "Class name must be a string" - - # Whitelist pattern: only allow alphanumeric, dash, underscore, and spaces - # Must contain 'fa-' for Font Awesome - fa_pattern = re.compile(r'^[a-zA-Z0-9\s_-]*fa-[a-zA-Z0-9-]+[a-zA-Z0-9\s_-]*$') - - if not fa_pattern.match(class_name): - return False, "Invalid Font Awesome class name format" - - if 'fa-' not in class_name: - return False, "Font Awesome class must contain 'fa-'" - - return True, None - - def validate_file_upload(filename: str, max_size_mb: int = 10, allowed_extensions: Optional[List[str]] = None) -> Tuple[bool, Optional[str]]: """ @@ -119,117 +35,6 @@ def validate_file_upload(filename: str, max_size_mb: int = 10, return True, None -def validate_mime_type(file_path: str, allowed_types: List[str]) -> Tuple[bool, Optional[str]]: - """ - Validate file MIME type. - - Args: - file_path: Path to the file - allowed_types: List of allowed MIME types (e.g., ['image/png', 'image/jpeg']) - - Returns: - Tuple of (is_valid, error_message) - """ - try: - import mimetypes - mime_type, _ = mimetypes.guess_type(file_path) - - if not mime_type: - return False, "Could not determine file type" - - if mime_type not in allowed_types: - return False, f"File type '{mime_type}' not allowed. Allowed types: {', '.join(allowed_types)}" - - return True, None - except Exception as e: - return False, f"Error validating MIME type: {str(e)}" - - -def validate_numeric_range(value: float, min_val: Optional[float] = None, - max_val: Optional[float] = None) -> Tuple[bool, Optional[str]]: - """ - Validate numeric value is within range. - - Returns: - Tuple of (is_valid, error_message) - """ - # bool is an int subclass, so True would otherwise validate as 1. - if not isinstance(value, (int, float)) or isinstance(value, bool): - return False, "Value must be a number" - - if min_val is not None and value < min_val: - return False, f"Value must be at least {min_val}" - - if max_val is not None and value > max_val: - return False, f"Value must be at most {max_val}" - - return True, None - - -def validate_string_length(text: str, min_length: Optional[int] = None, - max_length: Optional[int] = None) -> Tuple[bool, Optional[str]]: - """ - Validate string length. - - Returns: - Tuple of (is_valid, error_message) - """ - if not isinstance(text, str): - return False, "Value must be a string" - - length = len(text) - - if min_length is not None and length < min_length: - return False, f"String must be at least {min_length} characters" - - if max_length is not None and length > max_length: - return False, f"String must be at most {max_length} characters" - - return True, None - - -def sanitize_plugin_config(config: dict) -> dict: - """ - Restrict a plugin config to safe key names and value types. - - Drops keys that are not plain identifiers and values that are not - JSON-ish scalars, lists, or dicts, recursing into the latter two. - - String values are returned **unescaped**: output escaping is the - template layer's job, and escaping here would store the escaped form - in config.json. Do not read this function as XSS protection for - rendered output. - - Args: - config: Configuration dictionary - - Returns: - Sanitized configuration dictionary - """ - sanitized = {} - - for key, value in config.items(): - # Sanitize keys (no special characters) - if not isinstance(key, str) or not re.match(r'^[a-zA-Z0-9_]+$', key): - continue # Skip invalid keys - - # Sanitize values based on type - if isinstance(value, str): - # For string values, escape HTML but preserve the string - sanitized[key] = value # Don't escape - let templates handle it - elif isinstance(value, (int, float, bool)): - sanitized[key] = value - elif isinstance(value, list): - sanitized[key] = [sanitize_plugin_config(item) if isinstance(item, dict) else item for item in value] - elif isinstance(value, dict): - sanitized[key] = sanitize_plugin_config(value) - else: - # Skip unknown types - continue - - return sanitized - - def dedup_unique_arrays(cfg: dict, schema_node: dict) -> None: """Recursively deduplicate arrays with uniqueItems constraint. diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index c5f496ac..7c2b46a0 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -358,22 +358,6 @@ "POST" ] ], - [ - "/api/v3/plugins/authenticate/spotify", - "api_v3.authenticate_spotify", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/authenticate/ytm", - "api_v3.authenticate_ytm", - [ - "OPTIONS", - "POST" - ] - ], [ "/api/v3/plugins/calendar/authenticate", "api_v3.authenticate_calendar", @@ -511,22 +495,6 @@ "POST" ] ], - [ - "/api/v3/plugins/of-the-day/json/delete", - "api_v3.delete_of_the_day_json", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/of-the-day/json/upload", - "api_v3.upload_of_the_day_json", - [ - "OPTIONS", - "POST" - ] - ], [ "/api/v3/plugins/operation/", "api_v3.get_operation_status", diff --git a/test/test_api_v3_lazy_plugin_discovery.py b/test/test_api_v3_lazy_plugin_discovery.py index 19c1e8fc..1dffae81 100644 --- a/test/test_api_v3_lazy_plugin_discovery.py +++ b/test/test_api_v3_lazy_plugin_discovery.py @@ -76,13 +76,12 @@ def fresh_web_process(api_v3_module, plugins_dir): @pytest.fixture -def display_service(): +def display_service(api_v3_module): """Keep on-demand start away from systemctl and the real cache.""" - with patch('web_interface.blueprints.api_v3.display._ensure_cache_manager') as cache, \ - patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status: - cache.return_value = MagicMock() + cache = api_v3_module.api_v3.cache_manager = MagicMock() + with patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status: status.return_value = {'active': True} - yield cache.return_value + yield cache def _start(client, **body): diff --git a/test/test_api_v3_music_auth_endpoints.py b/test/test_api_v3_music_auth_endpoints.py deleted file mode 100644 index f7c709af..00000000 --- a/test/test_api_v3_music_auth_endpoints.py +++ /dev/null @@ -1,302 +0,0 @@ -""" -Endpoint tests for /plugins/authenticate/spotify and .../ytm. - -The Spotify step-2 handler writes a Python wrapper script to a temp file -with the user's redirect URL embedded in it, then runs that file through -subprocess. That is the most dangerous shape in the blueprint and had no -tests: the URL is user input reaching generated source code. - -The two endpoints are NOT symmetrical, despite the matching names. Only -Spotify has a two-step flow, a wrapper script, and a redirect_url; YTM -just runs its script directly. - -Regression coverage for one fixed bug: the wrapper file was unlinked in -the success/failure branch and again in the TimeoutExpired handler, so -any other failure from subprocess.run — the interpreter missing, a fork -failure, an interrupted call — left a temp file containing the user's -redirect URL behind. -""" - -import ast -import json -import os -import subprocess -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 - - -@pytest.fixture -def plugin_dir(tmp_path, api_v3_module): - """A plugin directory containing both auth scripts.""" - directory = tmp_path / "plugins" / "ledmatrix-music" - directory.mkdir(parents=True) - (directory / "authenticate_spotify.py").write_text("print('spotify')\n") - (directory / "authenticate_ytm.py").write_text("print('ytm')\n") - api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory) - return directory - - -def completed(returncode=0, stdout="ok", stderr=""): - return subprocess.CompletedProcess( - args=["python3"], returncode=returncode, stdout=stdout, stderr=stderr) - - -class TestSpotifyPreconditions: - URL = "/api/v3/plugins/authenticate/spotify" - - def test_missing_plugin_directory_is_404(self, api_v3_client, api_v3_module, tmp_path): - api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str( - tmp_path / "not-installed") - response = api_v3_client.post(self.URL, json={}) - assert response.status_code == 404 - assert response.get_json()["message"] == "Plugin not found" - - def test_none_plugin_directory_is_404(self, api_v3_client, api_v3_module): - api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = None - assert api_v3_client.post(self.URL, json={}).status_code == 404 - - def test_missing_auth_script_is_404(self, api_v3_client, plugin_dir): - (plugin_dir / "authenticate_spotify.py").unlink() - response = api_v3_client.post(self.URL, json={}) - assert response.status_code == 404 - assert "script not found" in response.get_json()["message"] - - -class TestSpotifyStepTwo: - """redirect_url present — the wrapper-script path.""" - - URL = "/api/v3/plugins/authenticate/spotify" - - def test_success(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed(0, "done")): - response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - assert response.status_code == 200 - body = response.get_json() - assert body["status"] == "success" - assert body["output"] == "done" - - def test_script_failure_is_a_400_with_combined_output(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed(1, "out", "err")): - response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - assert response.status_code == 400 - assert response.get_json()["output"] == "outerr" - - def test_timeout_is_a_408(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", - side_effect=subprocess.TimeoutExpired("python3", 120)): - response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - assert response.status_code == 408 - assert "timed out" in response.get_json()["message"] - - def test_runs_a_list_argv_never_a_shell(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed()) as run: - api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - args, kwargs = run.call_args - assert isinstance(args[0], list) - assert args[0][0] == "python3" - assert kwargs.get("shell") in (None, False) - - def test_timeout_is_bounded(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed()) as run: - api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - assert run.call_args.kwargs["timeout"] == 120 - - -class TestSpotifyWrapperCleanup: - URL = "/api/v3/plugins/authenticate/spotify" - - def _wrapper_paths_after(self, api_v3_client, run_mock): - """Run the endpoint and return the wrapper path subprocess saw.""" - seen = {} - - def capture(args, **kwargs): - seen["path"] = args[1] - return run_mock(args, **kwargs) - - with patch.object(subprocess, "run", side_effect=capture): - api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - return seen["path"] - - def test_removed_after_success(self, api_v3_client, plugin_dir): - path = self._wrapper_paths_after(api_v3_client, lambda *a, **kw: completed()) - assert not os.path.exists(path) - - def test_removed_after_script_failure(self, api_v3_client, plugin_dir): - path = self._wrapper_paths_after( - api_v3_client, lambda *a, **kw: completed(1, "out", "err")) - assert not os.path.exists(path) - - def test_removed_after_timeout(self, api_v3_client, plugin_dir): - def raise_timeout(*a, **kw): - raise subprocess.TimeoutExpired("python3", 120) - path = self._wrapper_paths_after(api_v3_client, raise_timeout) - assert not os.path.exists(path) - - def test_removed_when_subprocess_cannot_start(self, api_v3_client, plugin_dir): - # Regression: cleanup lived in the success/failure branch and in the - # TimeoutExpired handler only. An OSError from subprocess.run itself - # — no interpreter, fork failure — skipped both and left the wrapper, - # which contains the user's redirect URL, on disk. - def raise_oserror(*a, **kw): - raise OSError("[Errno 12] Cannot allocate memory") - path = self._wrapper_paths_after(api_v3_client, raise_oserror) - assert not os.path.exists(path) - - -class TestSpotifyRedirectUrlIsNotInjectable: - """The wrapper embeds redirect_url into generated Python source.""" - - URL = "/api/v3/plugins/authenticate/spotify" - - ADVERSARIAL = [ - '''http://cb/?code=x"''', - """http://cb/?code=x'""", - 'http://cb/?code=x\\', - 'http://cb/?code=x\nimport os; os.system("id")', - 'http://cb/?code=x"""\nimport os\n"""', - "http://cb/?code=x'''", - 'http://cb/?code=x\\"\\n', - '"; import os; os.system("id"); "', - ] - - def _wrapper_source(self, api_v3_client, redirect_url): - captured = {} - - def capture(args, **kwargs): - captured["source"] = Path(args[1]).read_text() - return completed() - - with patch.object(subprocess, "run", side_effect=capture): - api_v3_client.post(self.URL, json={"redirect_url": redirect_url}) - return captured["source"] - - @pytest.mark.parametrize("redirect_url", ADVERSARIAL) - def test_wrapper_is_still_valid_python(self, api_v3_client, plugin_dir, redirect_url): - # If escaping failed, the generated file would not parse at all. - source = self._wrapper_source(api_v3_client, redirect_url) - ast.parse(source) - - @pytest.mark.parametrize("redirect_url", ADVERSARIAL) - def test_url_survives_as_one_string_literal( - self, api_v3_client, plugin_dir, redirect_url): - # Stronger than "it parses": the URL must still be a single string - # assigned to redirect_url, not code that escaped into statements. - source = self._wrapper_source(api_v3_client, redirect_url) - tree = ast.parse(source) - assigned = [ - node.value.value for node in ast.walk(tree) - if isinstance(node, ast.Assign) - and isinstance(node.value, ast.Constant) - and any(getattr(t, "id", None) == "redirect_url" for t in node.targets) - ] - assert assigned == [redirect_url.strip()] - - def test_injected_call_does_not_become_a_statement(self, api_v3_client, plugin_dir): - source = self._wrapper_source( - api_v3_client, 'http://cb/\nimport os; os.system("id")') - tree = ast.parse(source) - imported = { - alias.name for node in ast.walk(tree) - if isinstance(node, ast.Import) for alias in node.names - } - # The wrapper legitimately imports sys, subprocess and os; what it - # must not gain is a *call* smuggled in through the URL. - calls = [ - node for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "system" - ] - assert calls == [] - - -class TestSpotifyStepOne: - """No redirect_url — the OAuth-URL path, which imports the script.""" - - URL = "/api/v3/plugins/authenticate/spotify" - - def test_script_without_credentials_helper_is_an_error( - self, api_v3_client, plugin_dir): - # The stub script defines neither get_auth_url nor - # load_spotify_credentials, so no URL can be produced. - response = api_v3_client.post(self.URL, json={}) - assert response.status_code in (400, 500) - assert response.get_json()["status"] == "error" - - def test_unusable_credentials_do_not_leak_into_the_response( - self, api_v3_client, plugin_dir): - (plugin_dir / "authenticate_spotify.py").write_text( - "def load_spotify_credentials():\n" - " return ('id-abc', 'super-secret-value', None)\n" - ) - response = api_v3_client.post(self.URL, json={}) - assert "super-secret-value" not in response.get_data(as_text=True) - - def test_script_raising_on_import_is_handled(self, api_v3_client, plugin_dir): - (plugin_dir / "authenticate_spotify.py").write_text("raise RuntimeError('boom')\n") - response = api_v3_client.post(self.URL, json={}) - assert response.status_code == 500 - assert response.get_json()["status"] == "error" - - def test_bodyless_post_reaches_step_one(self, api_v3_client, plugin_dir): - # Covered by the silent=True fix: previously a 500 from body parsing. - response = api_v3_client.post(self.URL) - assert response.status_code in (400, 500) - assert response.get_json()["status"] == "error" - - def test_whitespace_redirect_url_is_treated_as_absent( - self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed()) as run: - api_v3_client.post(self.URL, json={"redirect_url": " "}) - # Step 2 never runs, so no wrapper is executed. - run.assert_not_called() - - -class TestYouTubeMusic: - """No wrapper script and no redirect_url — deliberately not symmetric.""" - - URL = "/api/v3/plugins/authenticate/ytm" - - def test_missing_plugin_directory_is_404(self, api_v3_client, api_v3_module, tmp_path): - api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str( - tmp_path / "not-installed") - assert api_v3_client.post(self.URL).status_code == 404 - - def test_missing_script_is_404(self, api_v3_client, plugin_dir): - (plugin_dir / "authenticate_ytm.py").unlink() - response = api_v3_client.post(self.URL) - assert response.status_code == 404 - assert "script not found" in response.get_json()["message"] - - def test_success(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed(0, "authorized")): - response = api_v3_client.post(self.URL) - assert response.status_code == 200 - assert response.get_json()["output"] == "authorized" - - def test_failure_is_a_400_with_combined_output(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed(1, "out", "err")): - response = api_v3_client.post(self.URL) - assert response.status_code == 400 - assert response.get_json()["output"] == "outerr" - - def test_timeout_is_a_408(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", - side_effect=subprocess.TimeoutExpired("python3", 60)): - assert api_v3_client.post(self.URL).status_code == 408 - - def test_runs_the_script_directly_without_a_shell(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed()) as run: - api_v3_client.post(self.URL) - args, kwargs = run.call_args - assert args[0][0] == "python3" - assert args[0][1].endswith("authenticate_ytm.py") - assert kwargs.get("shell") in (None, False) - assert kwargs["timeout"] == 60 diff --git a/test/test_api_v3_on_demand_restart.py b/test/test_api_v3_on_demand_restart.py index c203fbe4..83f87834 100644 --- a/test/test_api_v3_on_demand_restart.py +++ b/test/test_api_v3_on_demand_restart.py @@ -24,7 +24,7 @@ and reported success. import sys from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -41,7 +41,8 @@ def restart_path(api_v3_module): plugin_manager and config_manager are set to None so the route takes the simplest path to that branch rather than tripping over unrelated - MagicMock plumbing; _ensure_cache_manager, _get_display_service_status, + MagicMock plumbing. The cache is the blueprint's cache_manager, which + api_v3_module already set to a MagicMock. _get_display_service_status, _stop_display_service and _ensure_display_service_running are bound by value in display.py (see its own docstring), so they are patched on that submodule rather than on the package. @@ -49,16 +50,13 @@ def restart_path(api_v3_module): api_v3_module.api_v3.plugin_manager = None api_v3_module.api_v3.config_manager = None - with patch("web_interface.blueprints.api_v3.display._ensure_cache_manager") as ensure_cache, \ - patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \ + with patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \ patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop_service, \ patch("web_interface.blueprints.api_v3.display._ensure_display_service_running") as ensure_running: - ensure_cache.return_value = MagicMock() # Active before the request: service_was_running becomes True. get_status.return_value = {"active": True} ensure_running.return_value = {"active": True} yield { - "ensure_cache": ensure_cache, "get_status": get_status, "stop_service": stop_service, "ensure_running": ensure_running, diff --git a/test/test_api_v3_optional_body.py b/test/test_api_v3_optional_body.py index fe1abc5e..921538c1 100644 --- a/test/test_api_v3_optional_body.py +++ b/test/test_api_v3_optional_body.py @@ -54,16 +54,6 @@ class TestResetPluginConfig: assert api_v3_client.post(self.URL, json={}).status_code != 500 -class TestDeleteOfTheDayJson: - URL = "/api/v3/plugins/of-the-day/json/delete" - - def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module): - assert api_v3_client.post(self.URL).status_code != 500 - - def test_json_body_still_works(self, api_v3_client, api_v3_module): - assert api_v3_client.post(self.URL, json={}).status_code != 500 - - class TestPluginLimits: URL = "/api/v3/plugins/clock/limits" diff --git a/test/test_path_traversal_guards.py b/test/test_path_traversal_guards.py index bce107cc..bc60ed18 100644 --- a/test/test_path_traversal_guards.py +++ b/test/test_path_traversal_guards.py @@ -182,57 +182,6 @@ class TestServePluginStatic: assert response.status_code == 404 -class TestDeleteOfTheDayJson: - """POST /api/v3/plugins/of-the-day/json/delete - - file_id came from the request body and was interpolated into - ``f"{file_id}.json"`` and then unlinked, with no validation at all. A - file_id of "../../../../etc/something" deleted that file. This is the one - finding in the batch that destroyed data rather than exposing it. - """ - - @pytest.fixture - def plugin_tree(self, tmp_path, api_v3_module): - plugin_dir = tmp_path / "plugin-repos" / "ledmatrix-of-the-day" - (plugin_dir / "of_the_day").mkdir(parents=True) - (plugin_dir / "of_the_day" / "quotes.json").write_text("{}", encoding="utf-8") - outside = tmp_path / "victim.json" - outside.write_text("important", encoding="utf-8") - - api_v3_module.api_v3.plugin_manager = MagicMock() - api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(plugin_dir) - return plugin_dir, outside - - URL = "/api/v3/plugins/of-the-day/json/delete" - - def test_a_real_file_in_the_plugin_is_still_deleted( - self, api_v3_client, plugin_tree - ): - plugin_dir, _ = plugin_tree - target = plugin_dir / "of_the_day" / "quotes.json" - response = api_v3_client.post(self.URL, json={"file_id": "quotes"}) - assert response.status_code == 200 - assert not target.exists() - - def test_a_traversing_file_id_deletes_nothing(self, api_v3_client, plugin_tree): - _, outside = plugin_tree - response = api_v3_client.post( - self.URL, json={"file_id": "../../../victim"} - ) - assert response.status_code == 400 - assert outside.exists(), "file outside the plugin directory was deleted" - assert outside.read_text(encoding="utf-8") == "important" - - @pytest.mark.parametrize("file_id", ["..", "a/b", "/etc/x", "x" + BACKSLASH + "y"]) - def test_other_shapes_of_traversal_are_refused( - self, api_v3_client, plugin_tree, file_id - ): - _, outside = plugin_tree - response = api_v3_client.post(self.URL, json={"file_id": file_id}) - assert response.status_code == 400 - assert outside.exists() - - class TestDiskCacheKeys: """The cache key becomes a filename, and POST /api/v3/cache/delete passes the request body's key straight through CacheManager.clear_cache to diff --git a/test/test_web_api.py b/test/test_web_api.py index 9a1bd1f7..ebc0cfdd 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -527,13 +527,11 @@ class TestDisplayAPI: if response.status_code in [200, 201]: assert api_v3.cache_manager.set.called - @patch('web_interface.blueprints.api_v3.display._ensure_cache_manager') - def test_stop_on_demand_display(self, mock_ensure_cache, client): + def test_stop_on_demand_display(self, client): """Test stopping on-demand display.""" - - # Mock the cache manager returned by _ensure_cache_manager - mock_cache_manager = MagicMock() - mock_ensure_cache.return_value = mock_cache_manager + from web_interface.blueprints.api_v3 import api_v3 + + mock_cache_manager = api_v3.cache_manager = MagicMock() response = client.post('/api/v3/display/on-demand/stop') diff --git a/test/test_web_display_state_freshness.py b/test/test_web_display_state_freshness.py index 3349db00..15d0c9f2 100644 --- a/test/test_web_display_state_freshness.py +++ b/test/test_web_display_state_freshness.py @@ -23,7 +23,7 @@ def two_processes(tmp_path, monkeypatch): monkeypatch.setattr(CacheManager, '_get_writable_cache_dir', lambda self: str(tmp_path)) monkeypatch.setattr(CacheManager, 'start_cleanup_thread', lambda self: None) display, web = CacheManager(), CacheManager() - monkeypatch.setattr(api_pkg, 'cache_manager', web) + monkeypatch.setattr(api_pkg.api_v3, 'cache_manager', web, raising=False) monkeypatch.setattr(api_pkg, '_get_display_service_status', lambda: {'active': True}) return display diff --git a/test/web_interface/test_api_v3_backup_restore.py b/test/web_interface/test_api_v3_backup_restore.py index 4c4a8304..3a0dd2ce 100644 --- a/test/web_interface/test_api_v3_backup_restore.py +++ b/test/web_interface/test_api_v3_backup_restore.py @@ -282,8 +282,7 @@ class TestPluginReinstall: def test_missing_store_manager_is_reported_per_plugin(self, client, restore): restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}]) api_v3.plugin_store_manager = None - with patch("web_interface.blueprints.api_v3.backup.plugin_store_manager", None): - body = post(client).get_json() + body = post(client).get_json() assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable" diff --git a/test/web_interface/test_validators.py b/test/web_interface/test_validators.py index 5f655d00..9d211b6e 100644 --- a/test/web_interface/test_validators.py +++ b/test/web_interface/test_validators.py @@ -1,135 +1,16 @@ """ -Tests for src/web_interface/validators.py. +Tests for validate_file_upload in src/web_interface/validators.py. -dedup_unique_arrays is already covered by test_dedup_unique_arrays.py and -is not repeated here; this file covers the other eight functions, none of -which had any tests. +dedup_unique_arrays is covered by test_dedup_unique_arrays.py. -Regression coverage for three fixed bugs: -- validate_numeric_range accepted True/False, since bool subclasses int. -- validate_file_upload lowercased the filename's extension but not the - caller's allowed_extensions list, so ['.TTF'] rejected 'font.ttf'. -- validate_image_url only checked for '..' inside the relative-path - branch, so http://host/../secret passed validation untouched. +Regression coverage: validate_file_upload lowercased the filename's +extension but not the caller's allowed_extensions list, so ['.TTF'] +rejected 'font.ttf'. """ import pytest -from src.web_interface.validators import ( - escape_html, - sanitize_plugin_config, - validate_file_upload, - validate_font_awesome_class, - validate_image_url, - validate_mime_type, - validate_numeric_range, - validate_string_length, -) - - -class TestEscapeHtml: - def test_escapes_all_five_entities(self): - assert escape_html("""O'Neill & co""") == ( - "<a href="x">O'Neill & co</a>") - - def test_ampersand_is_escaped_first_so_nothing_double_escapes(self): - # If '<' were replaced before '&', the '&' of '<' would be - # escaped again into '&lt;'. - assert escape_html("<") == "<" - assert escape_html("&") == "&" - assert escape_html("&<") == "&<" - - def test_plain_text_unchanged(self): - assert escape_html("hello world") == "hello world" - - def test_non_string_is_coerced(self): - assert escape_html(42) == "42" - assert escape_html(None) == "None" - - def test_script_tag_neutralized(self): - assert "") - - -class TestValidateImageUrl: - @pytest.mark.parametrize("url", [ - "javascript:alert(1)", - "JavaScript:alert(1)", - "JAVASCRIPT:alert(1)", - "data:text/html;base64,PHNjcmlwdD4=", - "vbscript:msgbox(1)", - "file:///etc/passwd", - ]) - def test_dangerous_protocols_rejected(self, url): - valid, error = validate_image_url(url) - assert valid is False and "protocol" in error.lower() - - @pytest.mark.parametrize("url", [ - "http://x/a.png?onerror=alert(1)", - "http://x/a.png#onload=alert(1)", - "http://x/onclick=alert(1).png", - ]) - def test_event_handlers_rejected(self, url): - valid, error = validate_image_url(url) - assert valid is False and "Event handlers" in error - - @pytest.mark.parametrize("url", ["", None, 123, []]) - def test_empty_or_non_string_rejected(self, url): - assert validate_image_url(url)[0] is False - - def test_http_and_https_allowed(self): - assert validate_image_url("http://example.com/logo.png") == (True, None) - assert validate_image_url("https://example.com/logo.png") == (True, None) - - def test_other_schemes_rejected(self): - valid, error = validate_image_url("ftp://example.com/logo.png") - assert valid is False and "http://" in error - - def test_relative_path_allowed(self): - assert validate_image_url("/static/logo.png") == (True, None) - - def test_protocol_relative_url_rejected(self): - assert validate_image_url("//evil.com/logo.png")[0] is False - - def test_relative_traversal_rejected(self): - assert validate_image_url("/static/../../etc/passwd")[0] is False - - def test_absolute_url_traversal_rejected(self): - # Regression: the '..' check used to sit inside the leading-slash - # branch, so an absolute URL skipped it entirely. - valid, error = validate_image_url("http://example.com/../secret") - assert valid is False and "traversal" in error.lower() - - def test_bare_traversal_rejected(self): - assert validate_image_url("../../etc/passwd")[0] is False - - -class TestValidateFontAwesomeClass: - @pytest.mark.parametrize("cls", ["fa-star", "fas fa-star", "fa-solid fa-house"]) - def test_valid_classes_accepted(self, cls): - assert validate_font_awesome_class(cls) == (True, None) - - @pytest.mark.parametrize("cls", ["star", "glyphicon-star", ""]) - def test_classes_without_fa_prefix_rejected(self, cls): - assert validate_font_awesome_class(cls)[0] is False - - def test_injection_attempt_rejected(self): - assert validate_font_awesome_class('fa-star" onload="alert(1)')[0] is False - - def test_angle_brackets_rejected(self): - assert validate_font_awesome_class("")[0] is False - - def test_non_string_rejected(self): - valid, error = validate_font_awesome_class(None) - assert valid is False and "string" in error - - def test_explicit_fa_check_is_unreachable_but_harmless(self): - # Characterized, not fixed: the regex already requires 'fa-', so the - # follow-up `if 'fa-' not in class_name` can never fire. Anything - # lacking 'fa-' is rejected by the pattern first, with the pattern's - # own message. - valid, error = validate_font_awesome_class("star") - assert valid is False - assert error == "Invalid Font Awesome class name format" +from src.web_interface.validators import validate_file_upload class TestValidateFileUpload: @@ -164,121 +45,3 @@ class TestValidateFileUpload: def test_no_extension_list_skips_the_check(self): assert validate_file_upload("anything.xyz") == (True, None) - - -class TestValidateMimeType: - def test_known_type_accepted(self): - assert validate_mime_type("logo.png", ["image/png"]) == (True, None) - - def test_mismatched_type_rejected(self): - valid, error = validate_mime_type("logo.png", ["image/jpeg"]) - assert valid is False and "not allowed" in error - - def test_undeterminable_type_rejected(self): - valid, error = validate_mime_type("mystery.zzz", ["image/png"]) - assert valid is False and "Could not determine" in error - - def test_guess_type_failure_is_caught(self, monkeypatch): - import mimetypes - monkeypatch.setattr(mimetypes, "guess_type", - lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("boom"))) - valid, error = validate_mime_type("logo.png", ["image/png"]) - assert valid is False and "Error validating MIME type" in error - - -class TestValidateNumericRange: - def test_value_in_range(self): - assert validate_numeric_range(5, min_val=0, max_val=10) == (True, None) - - def test_boundaries_are_inclusive(self): - assert validate_numeric_range(0, min_val=0, max_val=10) == (True, None) - assert validate_numeric_range(10, min_val=0, max_val=10) == (True, None) - - def test_below_minimum_rejected(self): - valid, error = validate_numeric_range(-1, min_val=0) - assert valid is False and "at least" in error - - def test_above_maximum_rejected(self): - valid, error = validate_numeric_range(11, max_val=10) - assert valid is False and "at most" in error - - def test_floats_accepted(self): - assert validate_numeric_range(2.5, min_val=0, max_val=10) == (True, None) - - def test_no_bounds_accepts_any_number(self): - assert validate_numeric_range(-9999) == (True, None) - - @pytest.mark.parametrize("value", ["5", None, [], {}]) - def test_non_numeric_rejected(self, value): - valid, error = validate_numeric_range(value, min_val=0, max_val=10) - assert valid is False and error == "Value must be a number" - - @pytest.mark.parametrize("value", [True, False]) - def test_booleans_rejected(self, value): - # Regression: bool subclasses int, so True passed the isinstance - # check and then compared as 1 against the range. - valid, error = validate_numeric_range(value, min_val=0, max_val=10) - assert valid is False and error == "Value must be a number" - - -class TestValidateStringLength: - def test_within_range(self): - assert validate_string_length("hello", min_length=1, max_length=10) == (True, None) - - def test_boundaries_are_inclusive(self): - assert validate_string_length("abc", min_length=3, max_length=3) == (True, None) - - def test_too_short_rejected(self): - valid, error = validate_string_length("", min_length=1) - assert valid is False and "at least" in error - - def test_too_long_rejected(self): - valid, error = validate_string_length("abcdef", max_length=3) - assert valid is False and "at most" in error - - def test_non_string_rejected(self): - valid, error = validate_string_length(123, max_length=10) - assert valid is False and "must be a string" in error - - def test_no_bounds_accepts_anything(self): - assert validate_string_length("") == (True, None) - - -class TestSanitizePluginConfig: - def test_valid_keys_and_scalars_kept(self): - config = {"enabled": True, "count": 3, "ratio": 1.5, "name": "clock"} - assert sanitize_plugin_config(config) == config - - @pytest.mark.parametrize("key", ["has space", "has-dash", "has.dot", "has/slash", ""]) - def test_invalid_key_names_dropped(self, key): - assert sanitize_plugin_config({key: "value", "good": 1}) == {"good": 1} - - def test_non_string_keys_dropped(self): - assert sanitize_plugin_config({1: "a", "good": 2}) == {"good": 2} - - def test_nested_dicts_recursed(self): - result = sanitize_plugin_config({"outer": {"inner": 1, "bad key": 2}}) - assert result == {"outer": {"inner": 1}} - - def test_list_of_scalars_preserved(self): - assert sanitize_plugin_config({"teams": ["PHI", "NYG"]})["teams"] == ["PHI", "NYG"] - - def test_list_of_dicts_recursed(self): - result = sanitize_plugin_config({"items": [{"ok": 1, "bad key": 2}]}) - assert result["items"] == [{"ok": 1}] - - def test_unknown_value_types_dropped(self): - assert sanitize_plugin_config({"weird": {1, 2, 3}, "good": 1}) == {"good": 1} - - def test_none_values_dropped(self): - assert sanitize_plugin_config({"nothing": None, "good": 1}) == {"good": 1} - - def test_strings_are_not_html_escaped(self): - # Pinned, not a bug: escaping here would persist the escaped form in - # config.json. Output escaping belongs to the template layer, which - # the function's docstring now says explicitly. - payload = "" - assert sanitize_plugin_config({"title": payload})["title"] == payload - - def test_empty_config(self): - assert sanitize_plugin_config({}) == {} diff --git a/web_interface/README.md b/web_interface/README.md index 9dd13376..d826cdbe 100644 --- a/web_interface/README.md +++ b/web_interface/README.md @@ -17,7 +17,6 @@ This directory contains the active V3 web interface with the following features: web_interface/ ├── app.py # Main Flask application ├── start.py # Startup script -├── run.sh # Shell runner script ├── requirements.txt # Python dependencies ├── blueprints/ # Flask blueprints │ ├── api_v3/ # API endpoints (package: config, display, @@ -27,7 +26,6 @@ web_interface/ ├── templates/ # HTML templates │ └── v3/ │ ├── base.html -│ ├── index.html │ └── partials/ └── static/ # CSS/JS assets └── v3/ @@ -49,11 +47,6 @@ From the project root: python3 web_interface/start.py ``` -Or using the shell script: -```bash -./web_interface/run.sh -``` - ### As a Service (Production) The web interface can run as a systemd service that starts automatically based on the `web_display_autostart` configuration setting: diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index 17ca4c3f..4119a1d8 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -85,20 +85,12 @@ def _scrub_git_remote_url(url: str) -> str: except Exception: pass return url -# Will be initialized when blueprint is registered # NOTE: the managers live on the blueprint object (app.py sets -# api_v3.config_manager / api_v3.plugin_manager). Deliberately not -# mirrored as module globals: a bare `config_manager` used to resolve to -# a None that was never assigned, which silently disabled the /health -# checks and made /display/current fall back to a hardcoded 128x64. -plugin_store_manager = None -saved_repositories_manager = None -cache_manager = None -schema_manager = None -operation_queue = None -plugin_state_manager = None -operation_history = None -sync_manager = None # Optional DisplaySyncManager instance (set by app.py if available) +# api_v3.config_manager, api_v3.plugin_manager, api_v3.cache_manager and +# the rest). Deliberately not mirrored as module globals: a bare +# `config_manager` used to resolve to a None that was never assigned, which +# silently disabled the /health checks and made /display/current fall back +# to a hardcoded 128x64. # Get project root directory (web_interface/../..) # web_interface/blueprints/api_v3/_common.py -> up four to the project root. # This was three levels when everything lived in web_interface/blueprints/api_v3.py; @@ -156,13 +148,6 @@ def _is_plugin_update_available(installed_version: str, latest_version: str) -> """ from src.plugin_system.compatibility import is_update_available return is_update_available(installed_version, latest_version) -def _ensure_cache_manager(): - """Ensure cache manager is initialized.""" - global cache_manager - if cache_manager is None: - from src.cache_manager import CacheManager - cache_manager = CacheManager() - return cache_manager def _save_config_atomic(config_manager, config_data, create_backup=True): """ Save configuration using atomic save if available, fallback to regular save. diff --git a/web_interface/blueprints/api_v3/backup.py b/web_interface/blueprints/api_v3/backup.py index 53e3a12d..f15220ea 100644 --- a/web_interface/blueprints/api_v3/backup.py +++ b/web_interface/blueprints/api_v3/backup.py @@ -5,8 +5,7 @@ endpoint names are unchanged by living here. """ from web_interface.blueprints.api_v3 import ( PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3, - datetime, json, jsonify, logger, os, plugin_store_manager, request, - tempfile, + datetime, json, jsonify, logger, os, request, tempfile, ) import web_interface.blueprints.api_v3 as _pkg # Read through the module rather than bound by value: tests patch these @@ -151,7 +150,7 @@ def backup_restore(): # Reinstall plugins if requested and store manager available if options.reinstall_plugins and result.plugins_to_install: - psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager + psm = getattr(api_v3, 'plugin_store_manager', None) for plug in result.plugins_to_install: pid = plug.get('plugin_id') if not pid: diff --git a/web_interface/blueprints/api_v3/display.py b/web_interface/blueprints/api_v3/display.py index 7b632c94..dcb69a50 100644 --- a/web_interface/blueprints/api_v3/display.py +++ b/web_interface/blueprints/api_v3/display.py @@ -4,7 +4,7 @@ Routes decorate the shared `api_v3` Blueprint from ._common, so their endpoint names are unchanged by living here. """ from web_interface.blueprints.api_v3 import ( - _ensure_cache_manager, _ensure_display_service_running, + _ensure_display_service_running, _get_display_service_status, _stop_display_service, api_v3, describe_exception, jsonify, logger, os, request, uuid, ) @@ -15,6 +15,19 @@ import web_interface.blueprints.api_v3 as _pkg # package is the only patch point that covers every caller. +def _cache_manager(): + """The web process's CacheManager, the one app.py puts on the blueprint. + + Created on first use when nothing set it (a test app, an embedder), and + stored back on the blueprint so every route shares that one instance. + """ + cache = getattr(api_v3, 'cache_manager', None) + if cache is None: + from src.cache_manager import CacheManager + cache = api_v3.cache_manager = CacheManager() + return cache + + @api_v3.route('/display/current', methods=['GET']) def get_display_current(): """Get current display state""" @@ -137,7 +150,7 @@ def get_display_modes(): def get_on_demand_status(): """Return the current on-demand display state.""" try: - cache = _ensure_cache_manager() + cache = _cache_manager() # memory_ttl=0: the display service writes this key, so only the file # is current. This process's memory tier would keep serving the first # copy it read for the full max_age -- "active" for two minutes after @@ -209,7 +222,7 @@ def start_on_demand_display(): # Set the on-demand request in cache FIRST (before starting service) # This ensures the request is available when the service starts/restarts - cache = _ensure_cache_manager() + cache = _cache_manager() request_id = data.get('request_id') or str(uuid.uuid4()) request_payload = { 'request_id': request_id, @@ -277,7 +290,7 @@ def stop_on_demand_display(): # Set the stop request in cache FIRST # The display controller will poll this and restart without the on-demand filter - cache = _ensure_cache_manager() + cache = _cache_manager() request_id = data.get('request_id') or str(uuid.uuid4()) request_payload = { 'request_id': request_id, @@ -313,7 +326,7 @@ def get_current_display_status(): process directly. """ try: - cache = _ensure_cache_manager() + cache = _cache_manager() # memory_ttl=0: written by the display service; see get_on_demand_status. state = cache.get('display_current_state', max_age=120, memory_ttl=0) if state is None: diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index 3ccf1a11..eba6b56e 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -16,7 +16,7 @@ from web_interface.blueprints.api_v3 import ( api_v3, datetime, deep_merge, describe_exception, error_response, find_secret_fields, hashlib, json, jsonify, logger, logging, merge_secrets, os, redact_text, remove_empty_secrets, request, - separate_secrets, shutil, stat, subprocess, success_response, sys, + separate_secrets, shutil, stat, subprocess, success_response, tempfile, uuid, validate_request_json, ) from src.common.path_safety import ( @@ -3181,195 +3181,6 @@ sys.exit(proc.returncode) except Exception as e: logger.error('Error in execute_plugin_action', exc_info=True) return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/plugins/authenticate/spotify', methods=['POST']) -def authenticate_spotify(): - """Run Spotify authentication script""" - try: - data = request.get_json(silent=True) or {} - redirect_url = data.get('redirect_url', '').strip() - - # Get plugin directory - plugin_id = 'ledmatrix-music' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - auth_script = Path(plugin_dir) / 'authenticate_spotify.py' - if not auth_script.exists(): - return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404 - - # Set LEDMATRIX_ROOT environment variable - env = os.environ.copy() - env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) - - if redirect_url: - # Step 2: Complete authentication with redirect URL - # Create a wrapper script that provides the redirect URL as input - import tempfile - - # Create a wrapper script that provides the redirect URL - import json - redirect_url_escaped = json.dumps(redirect_url) # Properly escape the URL - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper: - wrapper.write(f'''import sys -import subprocess -import os - -# Set LEDMATRIX_ROOT -os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}" - -# Run the auth script and provide redirect URL -proc = subprocess.Popen( - [sys.executable, r"{auth_script}"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=os.environ -) - -# Send redirect URL to stdin -redirect_url = {redirect_url_escaped} -stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120) -print(stdout) -sys.exit(proc.returncode) -''') - wrapper_path = wrapper.name - - try: - result = subprocess.run( - ['python3', wrapper_path], - capture_output=True, - text=True, - timeout=120, - env=env - ) - - if result.returncode == 0: - return jsonify({ - 'status': 'success', - 'message': 'Spotify authentication completed successfully', - 'output': result.stdout - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'Spotify authentication failed', - 'output': result.stdout + result.stderr - }), 400 - except subprocess.TimeoutExpired: - return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408 - finally: - # The wrapper carries the user's redirect URL, so it must not - # survive the request on any path — including a failure to - # launch, which the previous per-branch unlinks missed. - if os.path.exists(wrapper_path): - os.unlink(wrapper_path) - else: - # Step 1: Get authorization URL - # Import the script's functions directly to get the auth URL - import sys - import importlib.util - - # Load the authentication script as a module - spec = importlib.util.spec_from_file_location("auth_spotify", auth_script) - auth_module = importlib.util.module_from_spec(spec) - sys.modules["auth_spotify"] = auth_module - - # Set LEDMATRIX_ROOT before loading - os.environ['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) - - try: - spec.loader.exec_module(auth_module) - - # Get credentials and create OAuth object - client_id, client_secret, redirect_uri = auth_module.load_spotify_credentials() - if not all([client_id, client_secret, redirect_uri]): - return jsonify({ - 'status': 'error', - 'message': 'Could not load Spotify credentials. Please check config/config_secrets.json.' - }), 400 - - from spotipy.oauth2 import SpotifyOAuth - sp_oauth = SpotifyOAuth( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scope=auth_module.SCOPE, - cache_path=auth_module.SPOTIFY_AUTH_CACHE_PATH, - open_browser=False - ) - - auth_url = sp_oauth.get_authorize_url() - - return jsonify({ - 'status': 'success', - 'message': 'Authorization URL generated', - 'auth_url': auth_url - }) - except Exception as e: - logger.error("Error getting Spotify auth URL", exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) - }), 500 - - except Exception as e: - logger.error('Error in authenticate_spotify', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/plugins/authenticate/ytm', methods=['POST']) -def authenticate_ytm(): - """Run YouTube Music authentication script""" - try: - # Get plugin directory - plugin_id = 'ledmatrix-music' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - auth_script = Path(plugin_dir) / 'authenticate_ytm.py' - if not auth_script.exists(): - return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404 - - # Set LEDMATRIX_ROOT environment variable - env = os.environ.copy() - env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) - - # Run the authentication script - result = subprocess.run( - ['python3', str(auth_script)], - capture_output=True, - text=True, - timeout=60, - env=env - ) - - if result.returncode == 0: - return jsonify({ - 'status': 'success', - 'message': 'YouTube Music authentication completed successfully', - 'output': result.stdout - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'YouTube Music authentication failed', - 'output': result.stdout + result.stderr - }), 400 - - except subprocess.TimeoutExpired: - return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408 - except Exception as e: - logger.error('Error in authenticate_ytm', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 def _plugin_uploads_dir(plugin_id): """assets/plugins//uploads for a request-supplied id, or None. @@ -3527,210 +3338,6 @@ def upload_plugin_asset(): 'total_files': len(metadata) }) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST']) -def upload_of_the_day_json(): - """Upload JSON files for of-the-day plugin""" - try: - if 'files' not in request.files: - return jsonify({'status': 'error', 'message': 'No files provided'}), 400 - - files = request.files.getlist('files') - if not files or all(not f.filename for f in files): - return jsonify({'status': 'error', 'message': 'No files provided'}), 400 - - # Get plugin directory - plugin_id = 'ledmatrix-of-the-day' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - # Setup of_the_day directory - data_dir = Path(plugin_dir) / 'of_the_day' - data_dir.mkdir(parents=True, exist_ok=True) - - uploaded_files = [] - max_size_per_file = 5 * 1024 * 1024 # 5MB - - for file in files: - if not file.filename: - continue - - # Validate file extension - if not file.filename.lower().endswith('.json'): - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} must be a JSON file (.json)' - }), 400 - - # Read and validate file size - file.seek(0, os.SEEK_END) - file_size = file.tell() - file.seek(0) - - if file_size > max_size_per_file: - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} exceeds 5MB limit' - }), 400 - - # Read and validate JSON content - try: - file_content = file.read().decode('utf-8') - json_data = json.loads(file_content) - except json.JSONDecodeError as e: - return jsonify({ - 'status': 'error', - 'message': 'Invalid JSON in request body' - }), 400 - except UnicodeDecodeError: - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} is not valid UTF-8 text' - }), 400 - - # Validate JSON structure (must be object with day number keys) - if not isinstance(json_data, dict): - return jsonify({ - 'status': 'error', - 'message': f'JSON in {file.filename} must be an object with day numbers (1-365) as keys' - }), 400 - - # Check if keys are valid day numbers - for key in json_data.keys(): - try: - day_num = int(key) - if day_num < 1 or day_num > 365: - return jsonify({ - 'status': 'error', - 'message': f'Day number {day_num} in {file.filename} is out of range (must be 1-365)' - }), 400 - except ValueError: - return jsonify({ - 'status': 'error', - 'message': f'Invalid key "{key}" in {file.filename}: must be a day number (1-365)' - }), 400 - - # Generate safe filename from original (preserve user's filename) - original_filename = file.filename - safe_filename = original_filename.lower().replace(' ', '_') - # Ensure it's a valid filename - safe_filename = ''.join(c for c in safe_filename if c.isalnum() or c in '._-') - if not safe_filename.endswith('.json'): - safe_filename += '.json' - - file_path = data_dir / safe_filename - - # If file exists, add counter - counter = 1 - base_name = safe_filename.replace('.json', '') - while file_path.exists(): - safe_filename = f"{base_name}_{counter}.json" - file_path = data_dir / safe_filename - counter += 1 - - # Save file - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(json_data, f, indent=2, ensure_ascii=False) - - # Make file readable - os.chmod(file_path, 0o644) - - # Extract category name from filename (remove .json extension) - category_name = safe_filename.replace('.json', '') - display_name = category_name.replace('_', ' ').title() - - # Update plugin config to add category - try: - sys.path.insert(0, str(plugin_dir)) - from scripts.update_config import add_category_to_config - add_category_to_config(category_name, f'of_the_day/{safe_filename}', display_name) - except Exception as e: - logger.warning("Could not update config: %s", e) - # Continue anyway - file is uploaded - - # Generate file ID (use category name as ID for simplicity) - file_id = category_name - - uploaded_files.append({ - 'id': file_id, - 'filename': safe_filename, - 'original_filename': original_filename, - 'path': f'of_the_day/{safe_filename}', - 'size': file_size, - 'uploaded_at': datetime.utcnow().isoformat() + 'Z', - 'category_name': category_name, - 'display_name': display_name, - 'entry_count': len(json_data) - }) - - return jsonify({ - 'status': 'success', - 'uploaded_files': uploaded_files, - 'total_files': len(uploaded_files) - }) - - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST']) -def delete_of_the_day_json(): - """Delete a JSON file from of-the-day plugin""" - try: - data = request.get_json(silent=True) or {} - file_id = data.get('file_id') # This is the category_name - - if not file_id: - return jsonify({'status': 'error', 'message': 'file_id is required'}), 400 - - # file_id names a file that is about to be unlinked, and it arrives - # straight from the request body. Unvalidated, a file_id of - # "../../../../etc/cron" made this endpoint delete any .json file on - # the device the service could write to. - safe_file_id = safe_path_component(file_id) - if not safe_file_id: - return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400 - - # Get plugin directory - plugin_id = 'ledmatrix-of-the-day' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - filename = f"{safe_file_id}.json" - file_path = resolve_under(Path(plugin_dir) / 'of_the_day', filename) - if file_path is None: - return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400 - - if not file_path.exists(): - return jsonify({'status': 'error', 'message': f'File {filename} not found'}), 404 - - # Delete file - file_path.unlink() - - # Update config to remove category - try: - sys.path.insert(0, str(plugin_dir)) - from scripts.update_config import remove_category_from_config - remove_category_from_config(safe_file_id) - except Exception as e: - logger.warning("Could not update config: %s", e) - - return jsonify({ - 'status': 'success', - 'message': f'File {filename} deleted successfully' - }) - except Exception as e: logger.error('Unhandled exception', exc_info=True) return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 6f3072ff..e2b82c3d 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -1,4 +1,4 @@ -from flask import Blueprint, Response, render_template, flash, jsonify, url_for +from flask import Blueprint, Response, render_template, jsonify, url_for from jinja2 import TemplateNotFound from markupsafe import escape from html.parser import HTMLParser @@ -20,12 +20,8 @@ from web_interface import widget_bundle logger = logging.getLogger(__name__) -# Will be initialized when blueprint is registered -config_manager = None -plugin_manager = None -plugin_store_manager = None -schema_manager = None - +# The managers live on the blueprint object: app.py sets +# pages_v3.config_manager, pages_v3.plugin_manager and the rest. pages_v3 = Blueprint('pages_v3', __name__) @@ -162,37 +158,8 @@ _SEARCH_INDEX_CACHE = {'sig': None, 'fields': None} @pages_v3.route('/') def index(): - """Main v3 interface page""" - try: - if pages_v3.config_manager: - # Load configuration data - main_config = pages_v3.config_manager.load_config() - schedule_config = main_config.get('schedule', {}) - - # Get raw config files for JSON editor - main_config_data = pages_v3.config_manager.get_raw_file_content('main') - secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets') - main_config_json = json.dumps(main_config_data, indent=4) - secrets_config_json = json.dumps(secrets_config_data, indent=4) - else: - raise Exception("Config manager not initialized") - - except Exception as e: - flash(f"Error loading configuration: {e}", "error") - schedule_config = {} - main_config_json = "{}" - secrets_config_json = "{}" - main_config_data = {} - secrets_config_data = {} - - return render_template('v3/index.html', - schedule_config=schedule_config, - main_config_json=main_config_json, - secrets_config_json=secrets_config_json, - main_config_path=pages_v3.config_manager.get_config_path() if pages_v3.config_manager else "", - secrets_config_path=pages_v3.config_manager.get_secrets_path() if pages_v3.config_manager else "", - main_config=main_config_data, - secrets_config=secrets_config_data) + """Main v3 interface page: the app shell. Every tab loads as a partial.""" + return render_template('v3/base.html') @pages_v3.route('/partials/') def load_partial(partial_name): diff --git a/web_interface/logging_config.py b/web_interface/logging_config.py index 70a06c75..07c2f267 100644 --- a/web_interface/logging_config.py +++ b/web_interface/logging_config.py @@ -108,29 +108,3 @@ def log_api_request(method: str, path: str, status_code: int, duration_ms: float logger.warning(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra) else: logger.info(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra) - - -def log_config_change(change_type: str, target: str, success: bool, **kwargs): - """ - Log a configuration change. - - Args: - change_type: Type of change (save, delete, update) - target: What was changed (e.g., 'main_config', 'plugin_config:football-scoreboard') - success: Whether the change was successful - **kwargs: Additional context - """ - logger = logging.getLogger('web_interface.config') - - extra = { - 'change_type': change_type, - 'target': target, - 'success': success, - **kwargs - } - - if success: - logger.info(f"Config {change_type}: {target}", extra=extra) - else: - logger.error(f"Config {change_type} failed: {target}", extra=extra) - diff --git a/web_interface/run.sh b/web_interface/run.sh deleted file mode 100755 index c606bf88..00000000 --- a/web_interface/run.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -# LED Matrix Web Interface V3 Runner -# This script runs the web interface using system Python - -set -e - -# Get the directory where this script is located -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -cd "$PROJECT_ROOT" - -echo "Starting LED Matrix Web Interface V3..." - -# Run the web interface from project root -python3 web_interface/start.py - diff --git a/web_interface/static/v3/app.js b/web_interface/static/v3/app.js index 31d770bb..51b6f377 100644 --- a/web_interface/static/v3/app.js +++ b/web_interface/static/v3/app.js @@ -1,30 +1,7 @@ -/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */ +/* global showNotification */ // LED Matrix v3 JavaScript // Additional helpers for HTMX and Alpine.js integration -// Global notification system — implemented by widgets/notification.js. -// This fallback only exists if nothing defined showNotification earlier; it -// hands off to the widget when registered, otherwise queues the message for -// the widget to show once it loads. -if (typeof window.showNotification !== 'function') { - window.showNotification = function(message, type = 'info') { - const registry = window.LEDMatrixWidgets; - const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null; - if (widget && typeof widget.show === 'function') { - return widget.show(message, typeof type === 'string' ? { type: type } : (type || {})); - } - if (!Array.isArray(window.__pendingNotifications)) { - window.__pendingNotifications = []; - } - window.__pendingNotifications.push([message, type]); - document.dispatchEvent(new CustomEvent('show-notification', { - detail: { message, type } - })); - // User-facing last resort, so never gated - console.info(`${(type && type.type) || type}: ${message}`); - }; -} - // HTMX response handlers document.body.addEventListener('htmx:beforeRequest', function(event) { // Show loading states for buttons @@ -191,37 +168,6 @@ document.addEventListener('DOMContentLoaded', function() { // SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in // js/app-shell.js — do not open EventSources for stats/display here. -// Utility functions -window.hexToRgb = function(hex) { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : null; -}; - -window.rgbToHex = function(r, g, b) { - return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); -}; - -// Form validation helpers -window.validateForm = function(form) { - const inputs = form.querySelectorAll('input[required], select[required], textarea[required]'); - let isValid = true; - - inputs.forEach(input => { - if (!input.value.trim()) { - input.classList.add('border-red-500'); - isValid = false; - } else { - input.classList.remove('border-red-500'); - } - }); - - return isValid; -}; - // Auto-resize textareas document.addEventListener('DOMContentLoaded', function() { const textareas = document.querySelectorAll('textarea'); @@ -251,144 +197,12 @@ document.addEventListener('keydown', function(e) { } }); -// Plugin management helpers -window.installPlugin = function(pluginId) { - fetch('/api/v3/plugins/install', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plugin_id: pluginId }) - }) - .then(response => response.json()) - .then(data => { - showNotification(data.message, data.status); - if (data.status === 'success') { - // Refresh plugin list - htmx.ajax('GET', '/v3/partials/plugins', '#plugins-content'); - } - }) - .catch(error => { - showNotification('Error installing plugin: ' + error.message, 'error'); - }); -}; - -// Font management helpers -window.uploadFont = function(fileInput) { - const file = fileInput.files[0]; - if (!file) return; - - const formData = new FormData(); - formData.append('font_file', file); - formData.append('font_family', file.name.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9]/g, '_')); - - fetch('/api/v3/fonts/upload', { - method: 'POST', - body: formData - }) - .then(response => response.json()) - .then(data => { - showNotification(data.message, data.status); - if (data.status === 'success') { - // Refresh fonts list - htmx.ajax('GET', '/v3/partials/fonts', '#fonts-content'); - } - }) - .catch(error => { - showNotification('Error uploading font: ' + error.message, 'error'); - }); -}; - -// Tab switching helper -window.switchTab = function(tabName) { - // Update Alpine.js active tab if available - if (window.Alpine) { - // Dispatch event for Alpine.js - const event = new CustomEvent('switch-tab', { - detail: { tab: tabName } - }); - document.dispatchEvent(event); - } -}; - // Error handling for unhandled promise rejections window.addEventListener('unhandledrejection', function(event) { console.error('Unhandled promise rejection:', event.reason); showNotification('An unexpected error occurred', 'error'); }); -// Performance monitoring -window.performanceMonitor = { - startTime: performance.now(), - - mark: function(name) { - if (window.performance.mark) { - performance.mark(name); - } - }, - - measure: function(name, start, end) { - if (window.performance.measure) { - performance.measure(name, start, end); - } - }, - - getMeasures: function() { - if (window.performance && window.performance.getEntriesByType) { - return window.performance.getEntriesByType('measure'); - } - return []; - }, - - getMetrics: function() { - if (!window.performance || !window.performance.getEntriesByType) { - return {}; - } - - const navigation = window.performance.getEntriesByType('navigation')[0]; - const paint = window.performance.getEntriesByType('paint'); - const resources = window.performance.getEntriesByType('resource'); - - return { - domContentLoaded: navigation ? navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart : 0, - loadComplete: navigation ? navigation.loadEventEnd - navigation.fetchStart : 0, - firstPaint: paint.find(p => p.name === 'first-paint')?.startTime || 0, - firstContentfulPaint: paint.find(p => p.name === 'first-contentful-paint')?.startTime || 0, - resourceCount: resources.length, - totalResourceSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0), - measures: this.measures - }; - }, - - logMetrics: function() { - const metrics = this.getMetrics(); - console.group('Performance Metrics'); - debugLog('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms'); - debugLog('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms'); - debugLog('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms'); - debugLog('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms'); - debugLog('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB'); - if (Object.keys(metrics.measures || {}).length > 0) { - debugLog('Custom Measures:', metrics.measures); - } - console.groupEnd(); - } -}; - -// Initialize performance monitoring -document.addEventListener('DOMContentLoaded', function() { - window.performanceMonitor.mark('app-start'); - - // Log metrics after page load - window.addEventListener('load', function() { - setTimeout(() => { - window.performanceMonitor.mark('app-loaded'); - window.performanceMonitor.measure('app-load-time', 'app-start', 'app-loaded'); - if (window.location.search.includes('debug=perf')) { - window.performanceMonitor.logMetrics(); - } - }, 100); - }); -}); - // ===== Floating live preview ===== // A mini preview of the display, available on every tab except Overview // (which has the full-size one). Open/closed state persists per browser; diff --git a/web_interface/static/v3/js/app-shell.js b/web_interface/static/v3/js/app-shell.js index ab865447..5e815997 100644 --- a/web_interface/static/v3/js/app-shell.js +++ b/web_interface/static/v3/js/app-shell.js @@ -2572,23 +2572,6 @@ // Assign to window for global access window.uninstallPlugin = uninstallPlugin; - async function refreshPlugin(pluginId) { - try { - // Switch to the plugin manager tab briefly to refresh - const originalTab = app.activeTab; - app.activeTab = 'plugins'; - - // Wait a moment then switch back - setTimeout(() => { - app.activeTab = originalTab; - app.showNotification(`Refreshed ${pluginId}`, 'success'); - }, 100); - - } catch (error) { - app.showNotification('Error refreshing plugin: ' + error.message, 'error'); - } - } - // Format commit information for display function formatCommitInfo(commit, branch) { if (!commit && !branch) return 'Unknown'; diff --git a/web_interface/static/v3/js/config/diff_viewer.js b/web_interface/static/v3/js/config/diff_viewer.js deleted file mode 100644 index baa0eee4..00000000 --- a/web_interface/static/v3/js/config/diff_viewer.js +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Configuration diff viewer. - * - * Shows what changed in configuration before saving. - */ - -const ConfigDiffViewer = { - /** - * Original configuration state (before changes). - */ - originalConfigs: new Map(), - - /** - * Store original configuration for a plugin. - * - * @param {string} pluginId - Plugin identifier - * @param {Object} config - Original configuration - */ - storeOriginal(pluginId, config) { - this.originalConfigs.set(pluginId, JSON.parse(JSON.stringify(config))); - }, - - /** - * Get original configuration for a plugin. - * - * @param {string} pluginId - Plugin identifier - * @returns {Object|null} Original configuration - */ - getOriginal(pluginId) { - return this.originalConfigs.get(pluginId) || null; - }, - - /** - * Clear stored original configuration. - * - * @param {string} pluginId - Plugin identifier - */ - clearOriginal(pluginId) { - this.originalConfigs.delete(pluginId); - }, - - /** - * Compare two configuration objects and return differences. - * - * @param {Object} oldConfig - Old configuration - * @param {Object} newConfig - New configuration - * @returns {Object} Differences object with added, removed, and changed keys - */ - compare(oldConfig, newConfig) { - const differences = { - added: {}, - removed: {}, - changed: {}, - unchanged: {} - }; - - // Get all keys from both configs - const allKeys = new Set([ - ...Object.keys(oldConfig || {}), - ...Object.keys(newConfig || {}) - ]); - - for (const key of allKeys) { - const oldValue = oldConfig?.[key]; - const newValue = newConfig?.[key]; - - if (!(key in (oldConfig || {}))) { - // Key was added - differences.added[key] = newValue; - } else if (!(key in (newConfig || {}))) { - // Key was removed - differences.removed[key] = oldValue; - } else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { - // Key was changed - differences.changed[key] = { - old: oldValue, - new: newValue - }; - } else { - // Key unchanged - differences.unchanged[key] = oldValue; - } - } - - return differences; - }, - - /** - * Check if there are any differences. - * - * @param {Object} differences - Differences object from compare() - * @returns {boolean} True if there are changes - */ - hasChanges(differences) { - return Object.keys(differences.added).length > 0 || - Object.keys(differences.removed).length > 0 || - Object.keys(differences.changed).length > 0; - }, - - /** - * Format differences for display. - * - * @param {Object} differences - Differences object - * @returns {string} HTML formatted diff - */ - formatDiff(differences) { - const parts = []; - - // Added keys - if (Object.keys(differences.added).length > 0) { - parts.push(` -
-

- Added -

-
- ${Object.entries(differences.added).map(([key, value]) => ` -
- ${this.escapeHtml(key)} - = -
${this.escapeHtml(JSON.stringify(value, null, 2))}
-
- `).join('')} -
-
- `); - } - - // Removed keys - if (Object.keys(differences.removed).length > 0) { - parts.push(` -
-

- Removed -

-
- ${Object.entries(differences.removed).map(([key, value]) => ` -
- ${this.escapeHtml(key)} - = -
${this.escapeHtml(JSON.stringify(value, null, 2))}
-
- `).join('')} -
-
- `); - } - - // Changed keys - if (Object.keys(differences.changed).length > 0) { - parts.push(` -
-

- Changed -

-
- ${Object.entries(differences.changed).map(([key, change]) => ` -
- ${this.escapeHtml(key)} -
-
-
Old Value:
-
${this.escapeHtml(JSON.stringify(change.old, null, 2))}
-
-
-
New Value:
-
${this.escapeHtml(JSON.stringify(change.new, null, 2))}
-
-
-
- `).join('')} -
-
- `); - } - - if (parts.length === 0) { - return '
No changes detected
'; - } - - return parts.join(''); - }, - - /** - * Show diff modal before saving. - * - * @param {string} pluginId - Plugin identifier - * @param {Object} newConfig - New configuration - * @returns {Promise} Promise resolving to true if user confirms, false if cancelled - */ - async showDiffModal(pluginId, newConfig) { - return new Promise((resolve) => { - const original = this.getOriginal(pluginId); - if (!original) { - // No original to compare, proceed without diff - resolve(true); - return; - } - - const differences = this.compare(original, newConfig); - - if (!this.hasChanges(differences)) { - // No changes, proceed without showing modal - resolve(true); - return; - } - - // Create modal - const modalContainer = document.createElement('div'); - modalContainer.id = 'config-diff-modal-container'; - modalContainer.className = 'fixed inset-0 z-50 overflow-y-auto'; - modalContainer.innerHTML = ` -
- - -
-
-
- -
-

Review Configuration Changes

-
- ${this.formatDiff(differences)} -
-
-
-
-
- - -
-
-
- `; - - document.body.appendChild(modalContainer); - - let release = null; - let settled = false; - // Single close path: remove the modal, release the focus trap - // (returning focus to where the user was) and settle the promise. - function finish(result) { - if (settled) return; - settled = true; - modalContainer.remove(); - if (window.__configDiffResolve === finish) window.__configDiffResolve = undefined; - if (release) release(); - resolve(result); - } - - // Kept for backwards compatibility with code that calls it directly. - window.__configDiffResolve = finish; - - // Attach event listeners - const confirmBtn = modalContainer.querySelector('#config-diff-confirm-btn'); - const cancelBtn = modalContainer.querySelector('#config-diff-cancel-btn'); - const backdrop = modalContainer.querySelector('[data-diff-backdrop]'); - - confirmBtn.addEventListener('click', () => finish(true)); - cancelBtn.addEventListener('click', () => finish(false)); - if (backdrop) backdrop.addEventListener('click', () => finish(false)); - - if (window.LEDDialog) { - release = window.LEDDialog.trap(modalContainer.querySelector('#config-diff-modal-panel'), { - labelledBy: 'config-diff-modal-title', - initialFocus: confirmBtn, - onEscape: () => finish(false) - }); - } - }); - }, - - /** - * Escape HTML to prevent XSS. - */ - escapeHtml(text) { - if (typeof text !== 'string') { - text = String(text); - } - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } -}; - -// Export -if (typeof module !== 'undefined' && module.exports) { - module.exports = ConfigDiffViewer; -} else { - window.ConfigDiffViewer = ConfigDiffViewer; -} - diff --git a/web_interface/static/v3/js/htmx-config.js b/web_interface/static/v3/js/htmx-config.js index 8b9d6109..9b0f16a9 100644 --- a/web_interface/static/v3/js/htmx-config.js +++ b/web_interface/static/v3/js/htmx-config.js @@ -53,67 +53,6 @@ } }); - // Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy - const originalError = console.error; - const originalWarn = console.warn; - - console.error = function(...args) { - const errorStr = args.join(' '); - const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || ''; - - // Suppress HTMX insertBefore errors (comprehensive check) - // These occur when HTMX tries to swap content but the target element is null - // Usually happens due to timing/race conditions and is harmless - if (errorStr.includes("insertBefore") || - errorStr.includes("Cannot read properties of null") || - errorStr.includes("reading 'insertBefore'")) { - // Check if it's from HTMX by looking at stack trace or error string - // Also check the call stack if available - const isHtmxError = errorStr.includes('htmx') || - errorStack.includes('htmx') || - args.some(arg => { - if (typeof arg === 'string') { - return arg.includes('htmx'); - } - // Check error objects for stack traces - if (arg && typeof arg === 'object' && arg.stack) { - return arg.stack.includes('htmx'); - } - return false; - }); - - if (isHtmxError) { - return; // Suppress - this is a harmless HTMX timing/race condition issue - } - } - - // Suppress script execution errors from malformed HTML - if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") || - errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) { - if (errorStr.includes('Unexpected token')) { - return; // Suppress malformed HTML errors - } - } - originalError.apply(console, args); - }; - - console.warn = function(...args) { - const warnStr = args.join(' '); - // Suppress Permissions-Policy warnings (harmless browser warnings) - if (warnStr.includes('Permissions-Policy header') || - warnStr.includes('Unrecognized feature') || - warnStr.includes('Origin trial controlled feature') || - warnStr.includes('browsing-topics') || - warnStr.includes('run-ad-auction') || - warnStr.includes('join-ad-interest-group') || - warnStr.includes('private-state-token') || - warnStr.includes('private-aggregation') || - warnStr.includes('attribution-reporting')) { - return; // Suppress - these are harmless browser feature warnings - } - originalWarn.apply(console, args); - }; - // Handle HTMX errors gracefully with detailed logging document.body.addEventListener('htmx:responseError', function(event) { const detail = event.detail; diff --git a/web_interface/static/v3/js/htmx-sse.js b/web_interface/static/v3/js/htmx-sse.js deleted file mode 100644 index 49fe1b8f..00000000 --- a/web_interface/static/v3/js/htmx-sse.js +++ /dev/null @@ -1,356 +0,0 @@ -/* global htmx */ -/* -Server Sent Events Extension -============================ -This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. - -*/ - -(function() { - - /** @type {import("../htmx").HtmxInternalApi} */ - var api; - - htmx.defineExtension("sse", { - - /** - * Init saves the provided reference to the internal HTMX API. - * - * @param {import("../htmx").HtmxInternalApi} api - * @returns void - */ - init: function(apiRef) { - // store a reference to the internal API. - api = apiRef; - - // set a function in the public API for creating new EventSource objects - if (htmx.createEventSource == undefined) { - htmx.createEventSource = createEventSource; - } - }, - - /** - * onEvent handles all events passed to this extension. - * - * @param {string} name - * @param {Event} evt - * @returns void - */ - onEvent: function(name, evt) { - - switch (name) { - - case "htmx:beforeCleanupElement": - var internalData = api.getInternalData(evt.target) - // Try to remove remove an EventSource when elements are removed - if (internalData.sseEventSource) { - internalData.sseEventSource.close(); - } - - return; - - // Try to create EventSources when elements are processed - case "htmx:afterProcessNode": - ensureEventSourceOnElement(evt.target); - registerSSE(evt.target); - } - } - }); - - /////////////////////////////////////////////// - // HELPER FUNCTIONS - /////////////////////////////////////////////// - - - /** - * createEventSource is the default method for creating new EventSource objects. - * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. - * - * @param {string} url - * @returns EventSource - */ - function createEventSource(url) { - return new EventSource(url, { withCredentials: true }); - } - - function splitOnWhitespace(trigger) { - return trigger.trim().split(/\s+/); - } - - function getLegacySSEURL(elt) { - var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); - if (legacySSEValue) { - var values = splitOnWhitespace(legacySSEValue); - for (var i = 0; i < values.length; i++) { - var value = values[i].split(/:(.+)/); - if (value[0] === "connect") { - return value[1]; - } - } - } - } - - function getLegacySSESwaps(elt) { - var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); - var returnArr = []; - if (legacySSEValue != null) { - var values = splitOnWhitespace(legacySSEValue); - for (var i = 0; i < values.length; i++) { - var value = values[i].split(/:(.+)/); - if (value[0] === "swap") { - returnArr.push(value[1]); - } - } - } - return returnArr; - } - - /** - * registerSSE looks for attributes that can contain sse events, right - * now hx-trigger and sse-swap and adds listeners based on these attributes too - * the closest event source - * - * @param {HTMLElement} elt - */ - function registerSSE(elt) { - // Find closest existing event source - var sourceElement = api.getClosestMatch(elt, hasEventSource); - if (sourceElement == null) { - // api.triggerErrorEvent(elt, "htmx:noSSESourceError") - return null; // no eventsource in parentage, orphaned element - } - - // Set internalData and source - var internalData = api.getInternalData(sourceElement); - var source = internalData.sseEventSource; - - // Add message handlers for every `sse-swap` attribute - queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) { - - var sseSwapAttr = api.getAttributeValue(child, "sse-swap"); - if (sseSwapAttr) { - var sseEventNames = sseSwapAttr.split(","); - } else { - var sseEventNames = getLegacySSESwaps(child); - } - - for (var i = 0; i < sseEventNames.length; i++) { - var sseEventName = sseEventNames[i].trim(); - var listener = function(event) { - - // If the source is missing then close SSE - if (maybeCloseSSESource(sourceElement)) { - return; - } - - // If the body no longer contains the element, remove the listener - if (!api.bodyContains(child)) { - source.removeEventListener(sseEventName, listener); - } - - // swap the response into the DOM and trigger a notification - swap(child, event.data); - api.triggerEvent(elt, "htmx:sseMessage", event); - }; - - // Register the new listener - api.getInternalData(child).sseEventListener = listener; - source.addEventListener(sseEventName, listener); - } - }); - - // Add message handlers for every `hx-trigger="sse:*"` attribute - queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) { - - var sseEventName = api.getAttributeValue(child, "hx-trigger"); - if (sseEventName == null) { - return; - } - - // Only process hx-triggers for events with the "sse:" prefix - if (sseEventName.slice(0, 4) != "sse:") { - return; - } - - // remove the sse: prefix from here on out - sseEventName = sseEventName.substr(4); - - var listener = function() { - if (maybeCloseSSESource(sourceElement)) { - return - } - - if (!api.bodyContains(child)) { - source.removeEventListener(sseEventName, listener); - } - } - }); - } - - /** - * ensureEventSourceOnElement creates a new EventSource connection on the provided element. - * If a usable EventSource already exists, then it is returned. If not, then a new EventSource - * is created and stored in the element's internalData. - * @param {HTMLElement} elt - * @param {number} retryCount - * @returns {EventSource | null} - */ - function ensureEventSourceOnElement(elt, retryCount) { - - if (elt == null) { - return null; - } - - // handle extension source creation attribute - queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) { - var sseURL = api.getAttributeValue(child, "sse-connect"); - if (sseURL == null) { - return; - } - - ensureEventSource(child, sseURL, retryCount); - }); - - // handle legacy sse, remove for HTMX2 - queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) { - var sseURL = getLegacySSEURL(child); - if (sseURL == null) { - return; - } - - ensureEventSource(child, sseURL, retryCount); - }); - - } - - function ensureEventSource(elt, url, retryCount) { - var source = htmx.createEventSource(url); - - source.onerror = function(err) { - - // Log an error event - api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source }); - - // If parent no longer exists in the document, then clean up this EventSource - if (maybeCloseSSESource(elt)) { - return; - } - - // Otherwise, try to reconnect the EventSource - if (source.readyState === EventSource.CLOSED) { - retryCount = retryCount || 0; - var timeout = Math.random() * (2 ^ retryCount) * 500; - window.setTimeout(function() { - ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1)); - }, timeout); - } - }; - - source.onopen = function(evt) { - api.triggerEvent(elt, "htmx:sseOpen", { source: source }); - } - - api.getInternalData(elt).sseEventSource = source; - } - - /** - * maybeCloseSSESource confirms that the parent element still exists. - * If not, then any associated SSE source is closed and the function returns true. - * - * @param {HTMLElement} elt - * @returns boolean - */ - function maybeCloseSSESource(elt) { - if (!api.bodyContains(elt)) { - var source = api.getInternalData(elt).sseEventSource; - if (source != undefined) { - source.close(); - // source = null - return true; - } - } - return false; - } - - /** - * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. - * - * @param {HTMLElement} elt - * @param {string} attributeName - */ - function queryAttributeOnThisOrChildren(elt, attributeName) { - - var result = []; - - // If the parent element also contains the requested attribute, then add it to the results too. - if (api.hasAttribute(elt, attributeName)) { - result.push(elt); - } - - // Search all child nodes that match the requested attribute - elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) { - result.push(node); - }); - - return result; - } - - /** - * @param {HTMLElement} elt - * @param {string} content - */ - function swap(elt, content) { - - api.withExtensions(elt, function(extension) { - content = extension.transformResponse(content, null, elt); - }); - - var swapSpec = api.getSwapSpecification(elt); - var target = api.getTarget(elt); - var settleInfo = api.makeSettleInfo(elt); - - api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo); - - settleInfo.elts.forEach(function(elt) { - if (elt.classList) { - elt.classList.add(htmx.config.settlingClass); - } - api.triggerEvent(elt, 'htmx:beforeSettle'); - }); - - // Handle settle tasks (with delay if requested) - if (swapSpec.settleDelay > 0) { - setTimeout(doSettle(settleInfo), swapSpec.settleDelay); - } else { - doSettle(settleInfo)(); - } - } - - /** - * doSettle mirrors much of the functionality in htmx that - * settles elements after their content has been swapped. - * TODO: this should be published by htmx, and not duplicated here - * @param {import("../htmx").HtmxSettleInfo} settleInfo - * @returns () => void - */ - function doSettle(settleInfo) { - - return function() { - settleInfo.tasks.forEach(function(task) { - task.call(); - }); - - settleInfo.elts.forEach(function(elt) { - if (elt.classList) { - elt.classList.remove(htmx.config.settlingClass); - } - api.triggerEvent(elt, 'htmx:afterSettle'); - }); - } - } - - function hasEventSource(node) { - return api.getInternalData(node).sseEventSource != null; - } - -})(); diff --git a/web_interface/static/v3/js/plugins/store_manager.js b/web_interface/static/v3/js/plugins/store_manager.js deleted file mode 100644 index b223c087..00000000 --- a/web_interface/static/v3/js/plugins/store_manager.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Plugin store management. - * - * Handles plugin store browsing, searching, and installation. - */ - -const PluginStoreManager = { - /** - * Cache for plugin store data. - */ - cache: null, - cacheTimestamp: null, - CACHE_DURATION: 5 * 60 * 1000, // 5 minutes - - /** - * Load plugin store. - * - * @param {boolean} useCache - Whether to use cached data - * @returns {Promise} List of plugins - */ - async loadStore(useCache = true) { - // Check cache - if (useCache && this.cache && this.cacheTimestamp) { - const age = Date.now() - this.cacheTimestamp; - if (age < this.CACHE_DURATION) { - return this.cache; - } - } - - try { - const plugins = await window.PluginAPI.getPluginStore(); - this.cache = plugins; - this.cacheTimestamp = Date.now(); - return plugins; - } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, 'Failed to load plugin store'); - } - throw error; - } - }, - - /** - * Search plugin store. - * - * @param {string} query - Search query - * @returns {Promise} Filtered list of plugins - */ - async searchStore(query) { - const plugins = await this.loadStore(); - - if (!query || query.trim() === '') { - return plugins; - } - - const lowerQuery = query.toLowerCase(); - return plugins.filter(plugin => { - const name = (plugin.name || '').toLowerCase(); - const description = (plugin.description || '').toLowerCase(); - const author = (plugin.author || '').toLowerCase(); - const category = (plugin.category || '').toLowerCase(); - - return name.includes(lowerQuery) || - description.includes(lowerQuery) || - author.includes(lowerQuery) || - category.includes(lowerQuery); - }); - }, - - /** - * Install plugin from store. - * - * @param {string} pluginId - Plugin identifier - * @param {string} branch - Optional branch name to install from - * @returns {Promise} Installation result - */ - async installPlugin(pluginId, branch = null) { - try { - const result = await window.PluginAPI.installPlugin(pluginId, branch); - - // Clear cache - this.cache = null; - this.cacheTimestamp = null; - - return result; - } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, `Failed to install plugin ${pluginId}`); - } - throw error; - } - } -}; - -// Export -if (typeof module !== 'undefined' && module.exports) { - module.exports = PluginStoreManager; -} else { - window.PluginStoreManager = PluginStoreManager; -} - diff --git a/web_interface/static/v3/js/widgets/array-table.js b/web_interface/static/v3/js/widgets/array-table.js index 3c39b892..629f24ee 100644 --- a/web_interface/static/v3/js/widgets/array-table.js +++ b/web_interface/static/v3/js/widgets/array-table.js @@ -32,7 +32,7 @@ version: '2.0.0', render: function(container, config, value, options) { - console.log('[ArrayTableWidget] Render called (server-side rendered)'); + if (window.debugLog) window.debugLog('[ArrayTableWidget] Render called (server-side rendered)'); }, getValue: function(fieldId) { @@ -918,6 +918,4 @@ } else { initArrayTableButtons(); } - - console.log('[ArrayTableWidget] Array table widget registered (v2.0.0)'); })(); diff --git a/web_interface/static/v3/js/widgets/base-widget.js b/web_interface/static/v3/js/widgets/base-widget.js index 93c56b4c..fa9e7438 100644 --- a/web_interface/static/v3/js/widgets/base-widget.js +++ b/web_interface/static/v3/js/widgets/base-widget.js @@ -201,6 +201,4 @@ if (typeof window !== 'undefined') { window.BaseWidget = BaseWidget; } - - console.log('[BaseWidget] Base widget class loaded'); })(); diff --git a/web_interface/static/v3/js/widgets/checkbox-group.js b/web_interface/static/v3/js/widgets/checkbox-group.js index cbf02ad4..44014d63 100644 --- a/web_interface/static/v3/js/widgets/checkbox-group.js +++ b/web_interface/static/v3/js/widgets/checkbox-group.js @@ -31,7 +31,7 @@ render: function(container, config, value, options) { // For now, widgets are server-side rendered // This function is a placeholder for future client-side rendering - console.log('[CheckboxGroupWidget] Render called (server-side rendered)'); + if (window.debugLog) window.debugLog('[CheckboxGroupWidget] Render called (server-side rendered)'); }, /** @@ -116,6 +116,4 @@ }); hiddenInput.dispatchEvent(event); }; - - console.log('[CheckboxGroupWidget] Checkbox group widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/color-picker.js b/web_interface/static/v3/js/widgets/color-picker.js index 16bfd6a0..8802b28e 100644 --- a/web_interface/static/v3/js/widgets/color-picker.js +++ b/web_interface/static/v3/js/widgets/color-picker.js @@ -258,6 +258,4 @@ } } }); - - console.log('[ColorPickerWidget] Color picker widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/custom-feeds.js b/web_interface/static/v3/js/widgets/custom-feeds.js index c14c92ea..753931e5 100644 --- a/web_interface/static/v3/js/widgets/custom-feeds.js +++ b/web_interface/static/v3/js/widgets/custom-feeds.js @@ -31,7 +31,7 @@ render: function(container, config, value, options) { // For now, widgets are server-side rendered // This function is a placeholder for future client-side rendering - console.log('[CustomFeedsWidget] Render called (server-side rendered)'); + if (window.debugLog) window.debugLog('[CustomFeedsWidget] Render called (server-side rendered)'); }, /** @@ -523,6 +523,4 @@ event.target.value = ''; }); }; - - console.log('[CustomFeedsWidget] Custom feeds widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/date-picker.js b/web_interface/static/v3/js/widgets/date-picker.js index 44bca5f1..f0b4c848 100644 --- a/web_interface/static/v3/js/widgets/date-picker.js +++ b/web_interface/static/v3/js/widgets/date-picker.js @@ -189,6 +189,4 @@ } } }); - - console.log('[DatePickerWidget] Date picker widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/day-selector.js b/web_interface/static/v3/js/widgets/day-selector.js index 7ce18598..3a14ee85 100644 --- a/web_interface/static/v3/js/widgets/day-selector.js +++ b/web_interface/static/v3/js/widgets/day-selector.js @@ -254,6 +254,4 @@ // Expose DAYS constant for external use window.LEDMatrixWidgets.get('day-selector').DAYS = DAYS; window.LEDMatrixWidgets.get('day-selector').DAY_LABELS = DAY_LABELS; - - console.log('[DaySelectorWidget] Day selector widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/email-input.js b/web_interface/static/v3/js/widgets/email-input.js index 7efb9d39..f5beed4e 100644 --- a/web_interface/static/v3/js/widgets/email-input.js +++ b/web_interface/static/v3/js/widgets/email-input.js @@ -167,6 +167,4 @@ } } }); - - console.log('[EmailInputWidget] Email input widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/example-color-picker.js b/web_interface/static/v3/js/widgets/example-color-picker.js index a05e95f0..d94b2308 100644 --- a/web_interface/static/v3/js/widgets/example-color-picker.js +++ b/web_interface/static/v3/js/widgets/example-color-picker.js @@ -197,6 +197,4 @@ } } }); - - console.log('[ColorPickerWidget] Color picker widget registered (example)'); })(); diff --git a/web_interface/static/v3/js/widgets/file-upload-single.js b/web_interface/static/v3/js/widgets/file-upload-single.js index ff4e70c8..eb487e60 100644 --- a/web_interface/static/v3/js/widgets/file-upload-single.js +++ b/web_interface/static/v3/js/widgets/file-upload-single.js @@ -286,6 +286,4 @@ } } }); - - console.log('[FileUploadSingleWidget] File upload single widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/file-upload.js b/web_interface/static/v3/js/widgets/file-upload.js index fd4093cd..3cd026dc 100644 --- a/web_interface/static/v3/js/widgets/file-upload.js +++ b/web_interface/static/v3/js/widgets/file-upload.js @@ -32,7 +32,7 @@ render: function(container, config, value, options) { // For now, widgets are server-side rendered // This function is a placeholder for future client-side rendering - console.log('[FileUploadWidget] Render called (server-side rendered)'); + if (window.debugLog) window.debugLog('[FileUploadWidget] Render called (server-side rendered)'); }, /** @@ -1136,6 +1136,4 @@ window.updateImageList(fieldId, currentImages); } }; - - console.log('[FileUploadWidget] File upload widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/font-selector.js b/web_interface/static/v3/js/widgets/font-selector.js index c8c84d1d..f4cc4b89 100644 --- a/web_interface/static/v3/js/widgets/font-selector.js +++ b/web_interface/static/v3/js/widgets/font-selector.js @@ -311,6 +311,4 @@ generateDisplayName: generateDisplayName } }); - - console.log('[FontSelectorWidget] Font selector widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/google-calendar-picker.js b/web_interface/static/v3/js/widgets/google-calendar-picker.js index 21e772b9..05f32cca 100644 --- a/web_interface/static/v3/js/widgets/google-calendar-picker.js +++ b/web_interface/static/v3/js/widgets/google-calendar-picker.js @@ -192,6 +192,4 @@ .replace(/>/g, '>') .replace(/"/g, '"'); } - - console.log('[GoogleCalendarPickerWidget] registered'); })(); diff --git a/web_interface/static/v3/js/widgets/json-file-manager.js b/web_interface/static/v3/js/widgets/json-file-manager.js index ffa8bdf1..51aab85f 100644 --- a/web_interface/static/v3/js/widgets/json-file-manager.js +++ b/web_interface/static/v3/js/widgets/json-file-manager.js @@ -828,8 +828,5 @@ getValue() { return null; }, setValue() {} }); - console.log('[JsonFileManager] Registered with LEDMatrixWidgets'); - } else { - console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)'); } })(); diff --git a/web_interface/static/v3/js/widgets/notification.js b/web_interface/static/v3/js/widgets/notification.js index a854c766..cc684b1d 100644 --- a/web_interface/static/v3/js/widgets/notification.js +++ b/web_interface/static/v3/js/widgets/notification.js @@ -472,6 +472,4 @@ } else { flushPending(); } - - console.log('[NotificationWidget] Notification widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/number-input.js b/web_interface/static/v3/js/widgets/number-input.js index e7b90ab5..1b0cef3c 100644 --- a/web_interface/static/v3/js/widgets/number-input.js +++ b/web_interface/static/v3/js/widgets/number-input.js @@ -239,6 +239,4 @@ } } }); - - console.log('[NumberInputWidget] Number input widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/password-input.js b/web_interface/static/v3/js/widgets/password-input.js index e1aec8b3..88f0e904 100644 --- a/web_interface/static/v3/js/widgets/password-input.js +++ b/web_interface/static/v3/js/widgets/password-input.js @@ -316,6 +316,4 @@ } } }); - - console.log('[PasswordInputWidget] Password input widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/plugin-file-manager.js b/web_interface/static/v3/js/widgets/plugin-file-manager.js index 63672753..7a1af3a6 100644 --- a/web_interface/static/v3/js/widgets/plugin-file-manager.js +++ b/web_interface/static/v3/js/widgets/plugin-file-manager.js @@ -866,6 +866,4 @@ getValue: function () { return null; }, // file ops are immediate; nothing to submit setValue: function (fieldId) { loadFiles(fieldId); } }); - - console.log('[PluginFileManager] plugin-file-manager widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/plugin-loader.js b/web_interface/static/v3/js/widgets/plugin-loader.js index 77e6d5ce..bc3aa587 100644 --- a/web_interface/static/v3/js/widgets/plugin-loader.js +++ b/web_interface/static/v3/js/widgets/plugin-loader.js @@ -29,7 +29,7 @@ // Check if widget is already registered if (this.has(widgetName)) { - console.log(`[PluginWidgetLoader] Widget ${widgetName} already registered`); + if (window.debugLog) window.debugLog(`[PluginWidgetLoader] Widget ${widgetName} already registered`); return; } @@ -45,7 +45,7 @@ try { // Dynamic import of plugin widget await import(widgetPath); - console.log(`[PluginWidgetLoader] Loaded plugin widget: ${pluginId}/${widgetName} from ${widgetPath}`); + if (window.debugLog) window.debugLog(`[PluginWidgetLoader] Loaded plugin widget: ${pluginId}/${widgetName} from ${widgetPath}`); // Verify widget was registered if (this.has(widgetName)) { @@ -124,6 +124,4 @@ return loadedWidgets; }; - - console.log('[PluginWidgetLoader] Plugin widget loader initialized'); })(); diff --git a/web_interface/static/v3/js/widgets/radio-group.js b/web_interface/static/v3/js/widgets/radio-group.js index d7e488a3..726c7e0d 100644 --- a/web_interface/static/v3/js/widgets/radio-group.js +++ b/web_interface/static/v3/js/widgets/radio-group.js @@ -144,6 +144,4 @@ } } }); - - console.log('[RadioGroupWidget] Radio group widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/registry.js b/web_interface/static/v3/js/widgets/registry.js index a3687121..85f811b4 100644 --- a/web_interface/static/v3/js/widgets/registry.js +++ b/web_interface/static/v3/js/widgets/registry.js @@ -50,7 +50,7 @@ this._handlers.set(widgetName, definition.handlers); } - console.log(`[WidgetRegistry] Registered widget: ${widgetName}`); + if (window.debugLog) window.debugLog(`[WidgetRegistry] Registered widget: ${widgetName}`); return true; }, @@ -212,6 +212,4 @@ }))); }; } - - console.log('[WidgetRegistry] Widget registry initialized'); })(); diff --git a/web_interface/static/v3/js/widgets/select-dropdown.js b/web_interface/static/v3/js/widgets/select-dropdown.js index cdbc5e68..f8ad5f16 100644 --- a/web_interface/static/v3/js/widgets/select-dropdown.js +++ b/web_interface/static/v3/js/widgets/select-dropdown.js @@ -128,6 +128,4 @@ } } }); - - console.log('[SelectDropdownWidget] Select dropdown widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/slider.js b/web_interface/static/v3/js/widgets/slider.js index 3359dcd5..80135a1e 100644 --- a/web_interface/static/v3/js/widgets/slider.js +++ b/web_interface/static/v3/js/widgets/slider.js @@ -173,6 +173,4 @@ } } }); - - console.log('[SliderWidget] Slider widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js index c495cd6d..8f847c8e 100644 --- a/web_interface/static/v3/js/widgets/style-editor.js +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -768,6 +768,4 @@ return null; } }); - - console.log('[StyleEditor] widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/text-input.js b/web_interface/static/v3/js/widgets/text-input.js index 97c5fa09..a61638f9 100644 --- a/web_interface/static/v3/js/widgets/text-input.js +++ b/web_interface/static/v3/js/widgets/text-input.js @@ -239,6 +239,4 @@ } } }); - - console.log('[TextInputWidget] Text input widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/textarea.js b/web_interface/static/v3/js/widgets/textarea.js index c8d22fb4..d81e7359 100644 --- a/web_interface/static/v3/js/widgets/textarea.js +++ b/web_interface/static/v3/js/widgets/textarea.js @@ -175,6 +175,4 @@ } } }); - - console.log('[TextareaWidget] Textarea widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/time-picker.js b/web_interface/static/v3/js/widgets/time-picker.js index ad118805..2d9bf6bf 100644 --- a/web_interface/static/v3/js/widgets/time-picker.js +++ b/web_interface/static/v3/js/widgets/time-picker.js @@ -166,6 +166,4 @@ } } }); - - console.log('[TimePickerWidget] Time picker widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/time-range.js b/web_interface/static/v3/js/widgets/time-range.js index fddc3fa3..5b6f13f9 100644 --- a/web_interface/static/v3/js/widgets/time-range.js +++ b/web_interface/static/v3/js/widgets/time-range.js @@ -370,6 +370,4 @@ // Expose utility functions for external use window.LEDMatrixWidgets.get('time-range').parseTimeToMinutes = parseTimeToMinutes; window.LEDMatrixWidgets.get('time-range').calculateDuration = calculateDuration; - - console.log('[TimeRangeWidget] Time range widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/timezone-selector.js b/web_interface/static/v3/js/widgets/timezone-selector.js index 51c54127..1496c5dc 100644 --- a/web_interface/static/v3/js/widgets/timezone-selector.js +++ b/web_interface/static/v3/js/widgets/timezone-selector.js @@ -414,6 +414,4 @@ }, 50); }); })(); - - console.log('[TimezoneSelectorWidget] Timezone selector widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/toggle-switch.js b/web_interface/static/v3/js/widgets/toggle-switch.js index f4cc0dd2..eac77b85 100644 --- a/web_interface/static/v3/js/widgets/toggle-switch.js +++ b/web_interface/static/v3/js/widgets/toggle-switch.js @@ -220,6 +220,4 @@ } } }); - - console.log('[ToggleSwitchWidget] Toggle switch widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/url-input.js b/web_interface/static/v3/js/widgets/url-input.js index b2b09207..9931db5f 100644 --- a/web_interface/static/v3/js/widgets/url-input.js +++ b/web_interface/static/v3/js/widgets/url-input.js @@ -284,6 +284,4 @@ } } }); - - console.log('[UrlInputWidget] URL input widget registered'); })(); diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index b49e0d09..990d3e2a 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -41,164 +41,6 @@ const safeLocalStorage = { const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true'; if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...'); -// Expose on-demand functions early as stubs (will be replaced when IIFE runs) -window.openOnDemandModal = function(pluginId) { - console.warn('openOnDemandModal called before initialization, waiting...'); - // Wait for the real function to be available - let attempts = 0; - const maxAttempts = 50; // 2.5 seconds - const checkInterval = setInterval(() => { - attempts++; - if (window.__openOnDemandModalImpl) { - clearInterval(checkInterval); - window.__openOnDemandModalImpl(pluginId); - } else if (attempts >= maxAttempts) { - clearInterval(checkInterval); - console.error('openOnDemandModal not available after waiting'); - if (typeof showNotification === 'function') { - showNotification('On-demand modal unavailable. Please refresh the page.', 'error'); - } - } - }, 50); -}; - -window.requestOnDemandStop = function({ stopService = false } = {}) { - console.warn('requestOnDemandStop called before initialization, waiting...'); - // Wait for the real function to be available - let attempts = 0; - const maxAttempts = 50; // 2.5 seconds - const checkInterval = setInterval(() => { - attempts++; - if (window.__requestOnDemandStopImpl) { - clearInterval(checkInterval); - return window.__requestOnDemandStopImpl({ stopService }); - } else if (attempts >= maxAttempts) { - clearInterval(checkInterval); - console.error('requestOnDemandStop not available after waiting'); - if (typeof showNotification === 'function') { - showNotification('On-demand stop unavailable. Please refresh the page.', 'error'); - } - return Promise.reject(new Error('Function not available')); - } - }, 50); - return Promise.resolve(); -}; - -// Define updatePlugin early as a stub to ensure it's always available -window.updatePlugin = window.updatePlugin || function(pluginId) { - if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] updatePlugin called for', pluginId); - - // Validate pluginId - if (!pluginId || typeof pluginId !== 'string') { - console.error('Invalid pluginId:', pluginId); - if (typeof showNotification === 'function') { - showNotification('Invalid plugin ID', 'error'); - } - return Promise.reject(new Error('Invalid plugin ID')); - } - - // Show immediate feedback - if (typeof showNotification === 'function') { - showNotification(`Updating ${pluginId}...`, 'info'); - } - - // Prepare request body - const requestBody = { plugin_id: pluginId }; - const requestBodyJson = JSON.stringify(requestBody); - - debugLog('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson }); - - // Make the API call directly - return fetch('/api/v3/plugins/update', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: requestBodyJson - }) - .then(async response => { - // Check if response is OK before parsing - if (!response.ok) { - // Try to parse error response - let errorData; - try { - const text = await response.text(); - console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text }); - errorData = JSON.parse(text); - } catch (e) { - errorData = { message: `Server error: ${response.status} ${response.statusText}` }; - } - - if (typeof showNotification === 'function') { - showNotification(errorData.message || `Update failed: ${response.status}`, 'error'); - } - throw new Error(errorData.message || `Update failed: ${response.status}`); - } - - // Parse successful response - return response.json(); - }) - .then(data => { - if (typeof showNotification === 'function') { - showNotification(data.message || 'Update initiated', data.status || 'info'); - } - // Refresh installed plugins if available - if (typeof loadInstalledPlugins === 'function') { - loadInstalledPlugins(); - } else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') { - window.pluginManager.loadInstalledPlugins(); - } - return data; - }) - .catch(error => { - console.error('[UPDATE] Error updating plugin:', error); - if (typeof showNotification === 'function') { - showNotification('Error updating plugin: ' + error.message, 'error'); - } - throw error; - }); -}; - -// Define uninstallPlugin early as a stub -window.uninstallPlugin = window.uninstallPlugin || function(pluginId) { - if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] uninstallPlugin called for', pluginId); - - if (!confirm(`Are you sure you want to uninstall ${pluginId}?`)) { - return Promise.resolve({ cancelled: true }); - } - - if (typeof showNotification === 'function') { - showNotification(`Uninstalling ${pluginId}...`, 'info'); - } - - return fetch('/api/v3/plugins/uninstall', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plugin_id: pluginId }) - }) - .then(response => response.json()) - .then(data => { - if (typeof showNotification === 'function') { - showNotification(data.message || 'Uninstall initiated', data.status || 'info'); - } - // Refresh installed plugins if available - if (typeof loadInstalledPlugins === 'function') { - loadInstalledPlugins(); - } else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') { - window.pluginManager.loadInstalledPlugins(); - } - return data; - }) - .catch(error => { - console.error('Error uninstalling plugin:', error); - if (typeof showNotification === 'function') { - showNotification('Error uninstalling plugin: ' + error.message, 'error'); - } - throw error; - }); -}; - // Define configurePlugin early to ensure it's always available window.configurePlugin = window.configurePlugin || async function(pluginId) { if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] configurePlugin called for', pluginId); @@ -2122,7 +1964,6 @@ window.__openOnDemandModalImpl = function(pluginId) { }); }; -// Replace the stub with the real implementation window.openOnDemandModal = window.__openOnDemandModalImpl; // Release handle for the on-demand modal's focus trap (window.LEDDialog). @@ -2260,8 +2101,6 @@ function stopOnDemand(event) { requestOnDemandStop({ stopService }); } -// Store the real implementation and replace the stub -window.__requestOnDemandStopImpl = requestOnDemandStop; window.requestOnDemandStop = requestOnDemandStop; function closeOnDemandModalOnBackdrop(event) { @@ -2518,98 +2357,6 @@ window.updateKeyValuePairData = function(fieldId, fullKey) { hiddenInput.value = JSON.stringify(pairs); }; -// Functions to handle array-of-objects -window.addArrayObjectItem = function(fieldId, fullKey, maxItems) { - const itemsContainer = document.getElementById(fieldId + '_items'); - const hiddenInput = document.getElementById(fieldId + '_data'); - if (!itemsContainer || !hiddenInput) return; - - const currentItems = itemsContainer.querySelectorAll('.array-object-item'); - if (currentItems.length >= maxItems) { - alert(`Maximum ${maxItems} items allowed`); - return; - } - - // Get schema for item properties from the hidden input's data attribute or currentPluginConfig - const schema = (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema) || (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema); - if (!schema) return; - - // Navigate to the items schema - const keys = fullKey.split('.'); - let itemsSchema = schema.properties; - for (const key of keys) { - if (itemsSchema && itemsSchema[key]) { - itemsSchema = itemsSchema[key]; - if (itemsSchema.type === 'array' && itemsSchema.items) { - itemsSchema = itemsSchema.items; - break; - } - } - } - - if (!itemsSchema || !itemsSchema.properties) return; - - const newIndex = currentItems.length; - const itemHtml = renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema); - itemsContainer.insertAdjacentHTML('beforeend', itemHtml); - updateArrayObjectData(fieldId); - - // Update add button state - const addButton = itemsContainer.nextElementSibling; - if (addButton && currentItems.length + 1 >= maxItems) { - addButton.disabled = true; - addButton.style.opacity = '0.5'; - addButton.style.cursor = 'not-allowed'; - } -}; - -window.removeArrayObjectItem = function(fieldId, index) { - const itemsContainer = document.getElementById(fieldId + '_items'); - if (!itemsContainer) return; - - const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`); - if (item) { - item.remove(); - // Re-index remaining items - const remainingItems = itemsContainer.querySelectorAll('.array-object-item'); - remainingItems.forEach((itemEl, newIndex) => { - itemEl.setAttribute('data-index', newIndex); - // Update the id attribute to match new index (used by file upload selectors) - const newItemId = `${fieldId}_item_${newIndex}`; - itemEl.id = newItemId; - // Update all inputs within this item - need to update name/id attributes - itemEl.querySelectorAll('input, select, textarea').forEach(input => { - const name = input.getAttribute('name') || input.id; - if (name) { - // Update name/id attribute with new index - const newName = name.replace(/\[\d+\]/, `[${newIndex}]`); - if (input.getAttribute('name')) input.setAttribute('name', newName); - if (input.id) input.id = input.id.replace(/\d+/, newIndex); - } - }); - // Update button onclick attributes - itemEl.querySelectorAll('button[onclick]').forEach(button => { - const onclick = button.getAttribute('onclick'); - if (onclick) { - button.setAttribute('onclick', onclick.replace(/\d+/, newIndex)); - } - }); - }); - updateArrayObjectData(fieldId); - - // Update add button state - const addButton = itemsContainer.nextElementSibling; - if (addButton) { - const maxItems = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]); - if (remainingItems.length < maxItems) { - addButton.disabled = false; - addButton.style.opacity = '1'; - addButton.style.cursor = 'pointer'; - } - } - } -}; - window.updateArrayObjectData = function(fieldId) { const itemsContainer = document.getElementById(fieldId + '_items'); const hiddenInput = document.getElementById(fieldId + '_data'); @@ -3323,78 +3070,6 @@ window.executePluginAction = function(actionId, actionIndex, pluginIdParam = nul // togglePlugin is already defined at the top of the script - no need to redefine -// Only override updatePlugin if it doesn't already have improved error handling -if (!window.updatePlugin || window.updatePlugin.toString().includes('[UPDATE]')) { - window.updatePlugin = function(pluginId) { - // Validate pluginId - if (!pluginId || typeof pluginId !== 'string') { - console.error('[UPDATE] Invalid pluginId:', pluginId); - if (typeof showNotification === 'function') { - showNotification('Invalid plugin ID', 'error'); - } - return Promise.reject(new Error('Invalid plugin ID')); - } - - showNotification(`Updating ${pluginId}...`, 'info'); - - // Prepare request body - const requestBody = { plugin_id: pluginId }; - const requestBodyJson = JSON.stringify(requestBody); - - debugLog('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson }); - - return fetch('/api/v3/plugins/update', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: requestBodyJson - }) - .then(async response => { - // Check if response is OK before parsing - if (!response.ok) { - // Try to parse error response - let errorData; - try { - const text = await response.text(); - console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text }); - errorData = JSON.parse(text); - } catch (e) { - errorData = { message: `Server error: ${response.status} ${response.statusText}` }; - } - - if (typeof showNotification === 'function') { - showNotification(errorData.message || `Update failed: ${response.status}`, 'error'); - } - throw new Error(errorData.message || `Update failed: ${response.status}`); - } - - // Parse successful response - return response.json(); - }) - .then(data => { - showNotification(data.message || 'Update initiated', data.status || 'info'); - if (data.status === 'success') { - // Refresh the list - if (typeof loadInstalledPlugins === 'function') { - loadInstalledPlugins(); - } else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') { - window.pluginManager.loadInstalledPlugins(); - } - } - return data; - }) - .catch(error => { - console.error('[UPDATE] Error updating plugin:', error); - if (typeof showNotification === 'function') { - showNotification('Error updating plugin: ' + error.message, 'error'); - } - throw error; - }); - }; -} - window.uninstallPlugin = function(pluginId) { const plugin = (window.installedPlugins || installedPlugins || []).find(p => p.id === pluginId); const pluginName = plugin ? (plugin.name || pluginId) : pluginId; @@ -4529,34 +4204,6 @@ function jsStringAttr(value) { return escapeAttribute(JSON.stringify(value == null ? '' : String(value))); } -// Format date for display -function formatDate(dateString) { - if (!dateString) return 'Unknown'; - - try { - const date = new Date(dateString); - const now = new Date(); - const diffTime = Math.abs(now - date); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - if (diffDays < 1) { - return 'Today'; - } else if (diffDays < 2) { - return 'Yesterday'; - } else if (diffDays < 7) { - return `${diffDays} days ago`; - } else if (diffDays < 30) { - const weeks = Math.floor(diffDays / 7); - return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`; - } else { - // Return formatted date for older items - return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); - } - } catch (e) { - return dateString; - } -} - function isNewPlugin(lastUpdated) { if (!lastUpdated) return false; @@ -4915,10 +4562,6 @@ window.handleCredentialsUpload = async function(event, fieldId, uploadEndpoint, // handleFiles is now defined exclusively in file-upload.js widget -window.deleteUploadedImage = async function(fieldId, imageId, pluginId) { - return window.deleteUploadedFile(fieldId, imageId, pluginId, 'image', null); -} - window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) { const fileTypeLabel = fileType === 'json' ? 'file' : 'image'; if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) { @@ -4986,18 +4629,6 @@ window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, // getUploadConfig is defined in file-upload.js widget which loads first. // No override needed here — file-upload.js owns this function. -window.getCurrentImages = function(fieldId) { - const hiddenInput = document.getElementById(`${fieldId}_images_data`); - if (hiddenInput && hiddenInput.value) { - try { - return JSON.parse(hiddenInput.value); - } catch (e) { - console.error('Error parsing images data:', e); - } - } - return []; -} - window.updateImageList = function(fieldId, images) { const hiddenInput = document.getElementById(`${fieldId}_images_data`); if (hiddenInput) { @@ -5060,17 +4691,6 @@ window.updateImageList = function(fieldId, images) { } } -window.showUploadProgress = function(fieldId, totalFiles) { - const dropZone = document.getElementById(`${fieldId}_drop_zone`); - if (dropZone) { - dropZone.innerHTML = ` - -

Uploading ${totalFiles} file(s)...

- `; - dropZone.style.pointerEvents = 'none'; - } -} - window.hideUploadProgress = function(fieldId) { const uploadConfig = window.getUploadConfig(fieldId); const maxFiles = uploadConfig.max_files || 10; @@ -5088,14 +4708,6 @@ window.hideUploadProgress = function(fieldId) { } } -window.formatFileSize = function(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; -} - function formatDate(dateString) { if (!dateString) return 'Unknown date'; try { @@ -5106,30 +4718,6 @@ function formatDate(dateString) { } } -window.getScheduleSummary = function(schedule) { - if (!schedule || !schedule.enabled || schedule.mode === 'always') { - return 'Always shown'; - } - - if (schedule.mode === 'time_range') { - return `${schedule.start_time || '08:00'} - ${schedule.end_time || '18:00'} (daily)`; - } - - if (schedule.mode === 'per_day' && schedule.days) { - const enabledDays = Object.entries(schedule.days) - .filter(([day, config]) => config && config.enabled) - .map(([day]) => day.charAt(0).toUpperCase() + day.slice(1, 3)); - - if (enabledDays.length === 0) { - return 'Never shown'; - } - - return enabledDays.join(', ') + ' only'; - } - - return 'Scheduled'; -} - window.openImageSchedule = function(fieldId, imageId, imageIdx) { const currentImages = getCurrentImages(fieldId); const image = currentImages[imageIdx]; @@ -5516,14 +5104,6 @@ if (typeof window !== 'undefined') { } }; - // updateArrayObjectData is defined earlier in the file (line ~3596) - // Only define stub if it doesn't already exist (defensive fallback) - if (typeof window.updateArrayObjectData === 'undefined') { - window.updateArrayObjectData = function(fieldId) { - console.warn('updateArrayObjectData stub called - implementation should be defined earlier'); - }; - } - window.updateCheckboxGroupData = function(fieldId) { // Update hidden _data input with currently checked values const hiddenInput = document.getElementById(fieldId + '_data'); @@ -5542,22 +5122,6 @@ if (typeof window !== 'undefined') { hiddenInput.value = JSON.stringify(selectedValues); }; - // handleArrayObjectFileUpload and removeArrayObjectFile are defined earlier in the file - // Only define stubs if they don't already exist (defensive fallback) - if (typeof window.handleArrayObjectFileUpload === 'undefined') { - window.handleArrayObjectFileUpload = function(event, fieldId, itemIndex, propKey, pluginId) { - console.warn('handleArrayObjectFileUpload stub called - implementation should be defined earlier'); - window.updateArrayObjectData(fieldId); - }; - } - - if (typeof window.removeArrayObjectFile === 'undefined') { - window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) { - console.warn('removeArrayObjectFile stub called - implementation should be defined earlier'); - window.updateArrayObjectData(fieldId); - }; - } - // Debug logging (only if pluginDebug is enabled) if (_PLUGIN_DEBUG_EARLY) { debugLog('[ARRAY-OBJECTS] Functions defined on window:', { @@ -5576,23 +5140,6 @@ window.currentPluginConfig = null; // Force initialization immediately when script loads (for HTMX swapped content) debugLog('Plugins script loaded, checking for elements...'); -// Ensure all functions are globally available (in case IIFE didn't expose them properly) -// These should already be set inside the IIFE, but this ensures they're available -if (typeof initializePluginPageWhenReady !== 'undefined') { - window.initializePluginPageWhenReady = initializePluginPageWhenReady; -} -if (typeof initializePlugins !== 'undefined') { - window.initializePlugins = initializePlugins; -} -if (typeof loadInstalledPlugins !== 'undefined') { - window.loadInstalledPlugins = loadInstalledPlugins; -} -if (typeof renderInstalledPlugins !== 'undefined') { - window.renderInstalledPlugins = renderInstalledPlugins; -} -// GitHub install handlers are now exposed inside the IIFE (see above). -// searchPluginStore is also exposed inside the IIFE after its definition. - // Verify critical functions are available if (_PLUGIN_DEBUG_EARLY) { debugLog('Plugin functions available:', { @@ -5610,18 +5157,6 @@ if (window.checkGitHubAuthStatus && document.getElementById('github-auth-warning window.checkGitHubAuthStatus(); } -// Initialize on-demand modal immediately since it's in base.html -if (typeof initializeOnDemandModal === 'function') { - // Run immediately and also after DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeOnDemandModal); - } else { - initializeOnDemandModal(); - } - // Also try after a short delay to ensure elements are available - setTimeout(initializeOnDemandModal, 100); -} - setTimeout(function() { const installedGrid = document.getElementById('installed-plugins-grid'); if (installedGrid) { diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 81556ad2..39371dac 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -110,10 +110,8 @@ - - - - diff --git a/web_interface/templates/v3/index.html b/web_interface/templates/v3/index.html deleted file mode 100644 index c064dfd1..00000000 --- a/web_interface/templates/v3/index.html +++ /dev/null @@ -1,161 +0,0 @@ -{% extends "v3/base.html" %} - -{% block content %} -
-
-

System Overview

-

Monitor system status and manage your LED matrix display.

-
- - -
-
-
-
- -
-
-
-
CPU Usage
-
--%
-
-
-
-
- -
-
-
- -
-
-
-
Memory Usage
-
--%
-
-
-
-
- -
-
-
- -
-
-
-
CPU Temperature
-
--°C
-
-
-
-
- -
-
-
- -
-
-
-
Display Status
-
Unknown
-
-
-
-
-
- - -
-

Quick Actions

-
- - - - - - - -
-
- - -
-

Display Preview

-
-
- -

Display preview will appear here

-

Connect to see live updates

-
-
-
-
- - - -{% endblock %} diff --git a/web_interface/templates/v3/partials/fonts.html b/web_interface/templates/v3/partials/fonts.html index 5039a5ab..3994bd5d 100644 --- a/web_interface/templates/v3/partials/fonts.html +++ b/web_interface/templates/v3/partials/fonts.html @@ -152,7 +152,7 @@ function initializeFontsTab() { console.error('Fonts tab elements not found after max retries, giving up'); return; } - console.log('Fonts tab elements not found, retrying...', { + debugLog('Fonts tab elements not found, retrying...', { availableFonts: !!availableEl, attempt: initRetryCount }); @@ -178,7 +178,7 @@ function initializeFontsTab() { }; } - console.log('Initializing font management...'); + debugLog('Initializing font management...'); initializeFontManagement(); // Event listeners (use event delegation or ensure elements exist) @@ -229,7 +229,7 @@ function initializeFontsTab() { }); } - console.log('Fonts tab initialized successfully'); + debugLog('Fonts tab initialized successfully'); } // Expose initializeFontsTab to window for re-initialization after HTMX reload @@ -244,7 +244,7 @@ window.initializeFontsTab = initializeFontsTab; const fontsContent = document.getElementById('fonts-content'); if (fontsContent) { - console.log('Fonts content detected, initializing...'); + debugLog('Fonts content detected, initializing...'); setTimeout(() => { initializeFontsTab(); }, 50); @@ -257,7 +257,7 @@ window.initializeFontsTab = initializeFontsTab; // Check if the event target is the fonts-content container or contains it const target = event.target; if (target && (target.id === 'fonts-content' || target.querySelector && target.querySelector('#fonts-content'))) { - console.log('HTMX loaded fonts content, initializing...', target.id); + debugLog('HTMX loaded fonts content, initializing...', target.id); tryInitializeFontsTab(); } }); @@ -334,7 +334,7 @@ async function loadFontData() { // Update displays updateAvailableFontsDisplay(); - console.log('Font data loaded successfully', { + debugLog('Font data loaded successfully', { catalogSize: Object.keys(fontCatalog).length, tokensSize: Object.keys(fontTokens).length }); @@ -490,7 +490,7 @@ function populateFontSelects() { previewSelect.value = fontEntries[0].filename; } - console.log(`Populated font selects with ${fontEntries.length} fonts`); + debugLog(`Populated font selects with ${fontEntries.length} fonts`); } async function updateFontPreview() { diff --git a/web_interface/templates/v3/partials/wifi.html b/web_interface/templates/v3/partials/wifi.html index f460a1e1..e87b7e09 100644 --- a/web_interface/templates/v3/partials/wifi.html +++ b/web_interface/templates/v3/partials/wifi.html @@ -286,11 +286,11 @@ function wifiSetup() { try { const response = await fetch('/api/v3/wifi/scan'); const data = await response.json(); - console.log('WiFi scan response:', data); // Debug log + debugLog('WiFi scan response:', data); if (data.status === 'success') { // Ensure data.data is an array const networksArray = Array.isArray(data.data) ? data.data : []; - console.log('WiFi scan found networks:', networksArray.length); // Debug log + debugLog('WiFi scan found networks:', networksArray.length); // Set the networks array - Alpine.js will automatically update the select dropdown via x-for this.networks = networksArray;