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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* docs(api): drop the removed music-auth and of-the-day JSON routes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 12:36:53 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a231d4dbc7
commit a8b3e86775
62 changed files with 73 additions and 3149 deletions
+4 -51
View File
@@ -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 ### Upload Calendar Credentials
**POST** `/api/v3/plugins/calendar/upload-credentials` **POST** `/api/v3/plugins/calendar/upload-credentials`
@@ -1967,7 +1934,10 @@ restarted to pick up changes). See
## Plugin-specific endpoints ## 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 ### 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 plugin's config UI. Returns `calendars` at the top level. The upload and
authenticate endpoints are under [Plugins](#upload-calendar-credentials). 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 ### Plugin Static Assets
**GET** `/api/v3/plugins/<plugin_id>/static/<path:file_path>` **GET** `/api/v3/plugins/<plugin_id>/static/<path:file_path>`
-195
View File
@@ -1,94 +1,10 @@
""" """
Input validation utilities for the web interface. 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 typing import Optional, Tuple, List
from urllib.parse import urlparse
from pathlib import Path 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('&', '&amp;')
text = text.replace('<', '&lt;')
text = text.replace('>', '&gt;')
text = text.replace('"', '&quot;')
text = text.replace("'", '&#x27;')
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, def validate_file_upload(filename: str, max_size_mb: int = 10,
allowed_extensions: Optional[List[str]] = None) -> Tuple[bool, Optional[str]]: 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 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: def dedup_unique_arrays(cfg: dict, schema_node: dict) -> None:
"""Recursively deduplicate arrays with uniqueItems constraint. """Recursively deduplicate arrays with uniqueItems constraint.
-32
View File
@@ -358,22 +358,6 @@
"POST" "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/plugins/calendar/authenticate",
"api_v3.authenticate_calendar", "api_v3.authenticate_calendar",
@@ -511,22 +495,6 @@
"POST" "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/<operation_id>", "/api/v3/plugins/operation/<operation_id>",
"api_v3.get_operation_status", "api_v3.get_operation_status",
+4 -5
View File
@@ -76,13 +76,12 @@ def fresh_web_process(api_v3_module, plugins_dir):
@pytest.fixture @pytest.fixture
def display_service(): def display_service(api_v3_module):
"""Keep on-demand start away from systemctl and the real cache.""" """Keep on-demand start away from systemctl and the real cache."""
with patch('web_interface.blueprints.api_v3.display._ensure_cache_manager') as cache, \ cache = api_v3_module.api_v3.cache_manager = MagicMock()
patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status: with patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status:
cache.return_value = MagicMock()
status.return_value = {'active': True} status.return_value = {'active': True}
yield cache.return_value yield cache
def _start(client, **body): def _start(client, **body):
-302
View File
@@ -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
+4 -6
View File
@@ -24,7 +24,7 @@ and reported success.
import sys import sys
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import patch
import pytest 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 plugin_manager and config_manager are set to None so the route takes
the simplest path to that branch rather than tripping over unrelated 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 _stop_display_service and _ensure_display_service_running are bound by
value in display.py (see its own docstring), so they are patched on value in display.py (see its own docstring), so they are patched on
that submodule rather than on the package. 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.plugin_manager = None
api_v3_module.api_v3.config_manager = None api_v3_module.api_v3.config_manager = None
with patch("web_interface.blueprints.api_v3.display._ensure_cache_manager") as ensure_cache, \ with patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \
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._stop_display_service") as stop_service, \
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running") as ensure_running: 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. # Active before the request: service_was_running becomes True.
get_status.return_value = {"active": True} get_status.return_value = {"active": True}
ensure_running.return_value = {"active": True} ensure_running.return_value = {"active": True}
yield { yield {
"ensure_cache": ensure_cache,
"get_status": get_status, "get_status": get_status,
"stop_service": stop_service, "stop_service": stop_service,
"ensure_running": ensure_running, "ensure_running": ensure_running,
-10
View File
@@ -54,16 +54,6 @@ class TestResetPluginConfig:
assert api_v3_client.post(self.URL, json={}).status_code != 500 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: class TestPluginLimits:
URL = "/api/v3/plugins/clock/limits" URL = "/api/v3/plugins/clock/limits"
-51
View File
@@ -182,57 +182,6 @@ class TestServePluginStatic:
assert response.status_code == 404 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: class TestDiskCacheKeys:
"""The cache key becomes a filename, and POST /api/v3/cache/delete passes """The cache key becomes a filename, and POST /api/v3/cache/delete passes
the request body's key straight through CacheManager.clear_cache to the request body's key straight through CacheManager.clear_cache to
+4 -6
View File
@@ -527,13 +527,11 @@ class TestDisplayAPI:
if response.status_code in [200, 201]: if response.status_code in [200, 201]:
assert api_v3.cache_manager.set.called assert api_v3.cache_manager.set.called
@patch('web_interface.blueprints.api_v3.display._ensure_cache_manager') def test_stop_on_demand_display(self, client):
def test_stop_on_demand_display(self, mock_ensure_cache, client):
"""Test stopping on-demand display.""" """Test stopping on-demand display."""
from web_interface.blueprints.api_v3 import api_v3
# Mock the cache manager returned by _ensure_cache_manager
mock_cache_manager = MagicMock() mock_cache_manager = api_v3.cache_manager = MagicMock()
mock_ensure_cache.return_value = mock_cache_manager
response = client.post('/api/v3/display/on-demand/stop') response = client.post('/api/v3/display/on-demand/stop')
+1 -1
View File
@@ -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, '_get_writable_cache_dir', lambda self: str(tmp_path))
monkeypatch.setattr(CacheManager, 'start_cleanup_thread', lambda self: None) monkeypatch.setattr(CacheManager, 'start_cleanup_thread', lambda self: None)
display, web = CacheManager(), CacheManager() 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}) monkeypatch.setattr(api_pkg, '_get_display_service_status', lambda: {'active': True})
return display return display
@@ -282,8 +282,7 @@ class TestPluginReinstall:
def test_missing_store_manager_is_reported_per_plugin(self, client, restore): def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}]) restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
api_v3.plugin_store_manager = None 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" assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
+6 -243
View File
@@ -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 dedup_unique_arrays is covered by test_dedup_unique_arrays.py.
is not repeated here; this file covers the other eight functions, none of
which had any tests.
Regression coverage for three fixed bugs: Regression coverage: validate_file_upload lowercased the filename's
- validate_numeric_range accepted True/False, since bool subclasses int. extension but not the caller's allowed_extensions list, so ['.TTF']
- validate_file_upload lowercased the filename's extension but not the rejected 'font.ttf'.
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.
""" """
import pytest import pytest
from src.web_interface.validators import ( from src.web_interface.validators import validate_file_upload
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("""<a href="x">O'Neill & co</a>""") == (
"&lt;a href=&quot;x&quot;&gt;O&#x27;Neill &amp; co&lt;/a&gt;")
def test_ampersand_is_escaped_first_so_nothing_double_escapes(self):
# If '<' were replaced before '&', the '&' of '&lt;' would be
# escaped again into '&amp;lt;'.
assert escape_html("<") == "&lt;"
assert escape_html("&") == "&amp;"
assert escape_html("&<") == "&amp;&lt;"
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 "<script>" not in escape_html("<script>alert(1)</script>")
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("<script>fa-star</script>")[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"
class TestValidateFileUpload: class TestValidateFileUpload:
@@ -164,121 +45,3 @@ class TestValidateFileUpload:
def test_no_extension_list_skips_the_check(self): def test_no_extension_list_skips_the_check(self):
assert validate_file_upload("anything.xyz") == (True, None) 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 = "<script>alert(1)</script>"
assert sanitize_plugin_config({"title": payload})["title"] == payload
def test_empty_config(self):
assert sanitize_plugin_config({}) == {}
-7
View File
@@ -17,7 +17,6 @@ This directory contains the active V3 web interface with the following features:
web_interface/ web_interface/
├── app.py # Main Flask application ├── app.py # Main Flask application
├── start.py # Startup script ├── start.py # Startup script
├── run.sh # Shell runner script
├── requirements.txt # Python dependencies ├── requirements.txt # Python dependencies
├── blueprints/ # Flask blueprints ├── blueprints/ # Flask blueprints
│ ├── api_v3/ # API endpoints (package: config, display, │ ├── api_v3/ # API endpoints (package: config, display,
@@ -27,7 +26,6 @@ web_interface/
├── templates/ # HTML templates ├── templates/ # HTML templates
│ └── v3/ │ └── v3/
│ ├── base.html │ ├── base.html
│ ├── index.html
│ └── partials/ │ └── partials/
└── static/ # CSS/JS assets └── static/ # CSS/JS assets
└── v3/ └── v3/
@@ -49,11 +47,6 @@ From the project root:
python3 web_interface/start.py python3 web_interface/start.py
``` ```
Or using the shell script:
```bash
./web_interface/run.sh
```
### As a Service (Production) ### As a Service (Production)
The web interface can run as a systemd service that starts automatically based on the `web_display_autostart` configuration setting: The web interface can run as a systemd service that starts automatically based on the `web_display_autostart` configuration setting:
+5 -20
View File
@@ -85,20 +85,12 @@ def _scrub_git_remote_url(url: str) -> str:
except Exception: except Exception:
pass pass
return url return url
# Will be initialized when blueprint is registered
# NOTE: the managers live on the blueprint object (app.py sets # NOTE: the managers live on the blueprint object (app.py sets
# api_v3.config_manager / api_v3.plugin_manager). Deliberately not # api_v3.config_manager, api_v3.plugin_manager, api_v3.cache_manager and
# mirrored as module globals: a bare `config_manager` used to resolve to # the rest). Deliberately not mirrored as module globals: a bare
# a None that was never assigned, which silently disabled the /health # `config_manager` used to resolve to a None that was never assigned, which
# checks and made /display/current fall back to a hardcoded 128x64. # silently disabled the /health checks and made /display/current fall back
plugin_store_manager = None # to a hardcoded 128x64.
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)
# Get project root directory (web_interface/../..) # Get project root directory (web_interface/../..)
# web_interface/blueprints/api_v3/_common.py -> up four to the project root. # 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; # 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 from src.plugin_system.compatibility import is_update_available
return is_update_available(installed_version, latest_version) 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): def _save_config_atomic(config_manager, config_data, create_backup=True):
""" """
Save configuration using atomic save if available, fallback to regular save. Save configuration using atomic save if available, fallback to regular save.
+2 -3
View File
@@ -5,8 +5,7 @@ endpoint names are unchanged by living here.
""" """
from web_interface.blueprints.api_v3 import ( from web_interface.blueprints.api_v3 import (
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3, PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
datetime, json, jsonify, logger, os, plugin_store_manager, request, datetime, json, jsonify, logger, os, request, tempfile,
tempfile,
) )
import web_interface.blueprints.api_v3 as _pkg import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these # 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 # Reinstall plugins if requested and store manager available
if options.reinstall_plugins and result.plugins_to_install: 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: for plug in result.plugins_to_install:
pid = plug.get('plugin_id') pid = plug.get('plugin_id')
if not pid: if not pid:
+18 -5
View File
@@ -4,7 +4,7 @@ Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here. endpoint names are unchanged by living here.
""" """
from web_interface.blueprints.api_v3 import ( 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, _get_display_service_status, _stop_display_service, api_v3,
describe_exception, jsonify, logger, os, request, uuid, 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. # 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']) @api_v3.route('/display/current', methods=['GET'])
def get_display_current(): def get_display_current():
"""Get current display state""" """Get current display state"""
@@ -137,7 +150,7 @@ def get_display_modes():
def get_on_demand_status(): def get_on_demand_status():
"""Return the current on-demand display state.""" """Return the current on-demand display state."""
try: try:
cache = _ensure_cache_manager() cache = _cache_manager()
# memory_ttl=0: the display service writes this key, so only the file # 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 # 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 # 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) # Set the on-demand request in cache FIRST (before starting service)
# This ensures the request is available when the service starts/restarts # 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_id = data.get('request_id') or str(uuid.uuid4())
request_payload = { request_payload = {
'request_id': request_id, 'request_id': request_id,
@@ -277,7 +290,7 @@ def stop_on_demand_display():
# Set the stop request in cache FIRST # Set the stop request in cache FIRST
# The display controller will poll this and restart without the on-demand filter # 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_id = data.get('request_id') or str(uuid.uuid4())
request_payload = { request_payload = {
'request_id': request_id, 'request_id': request_id,
@@ -313,7 +326,7 @@ def get_current_display_status():
process directly. process directly.
""" """
try: try:
cache = _ensure_cache_manager() cache = _cache_manager()
# memory_ttl=0: written by the display service; see get_on_demand_status. # 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) state = cache.get('display_current_state', max_age=120, memory_ttl=0)
if state is None: if state is None:
+1 -394
View File
@@ -16,7 +16,7 @@ from web_interface.blueprints.api_v3 import (
api_v3, datetime, deep_merge, describe_exception, error_response, api_v3, datetime, deep_merge, describe_exception, error_response,
find_secret_fields, hashlib, json, jsonify, logger, logging, find_secret_fields, hashlib, json, jsonify, logger, logging,
merge_secrets, os, redact_text, remove_empty_secrets, request, 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, tempfile, uuid, validate_request_json,
) )
from src.common.path_safety import ( from src.common.path_safety import (
@@ -3181,195 +3181,6 @@ sys.exit(proc.returncode)
except Exception as e: except Exception as e:
logger.error('Error in execute_plugin_action', exc_info=True) 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 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): def _plugin_uploads_dir(plugin_id):
"""assets/plugins/<plugin_id>/uploads for a request-supplied id, or None. """assets/plugins/<plugin_id>/uploads for a request-supplied id, or None.
@@ -3527,210 +3338,6 @@ def upload_plugin_asset():
'total_files': len(metadata) '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: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
+5 -38
View File
@@ -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 jinja2 import TemplateNotFound
from markupsafe import escape from markupsafe import escape
from html.parser import HTMLParser from html.parser import HTMLParser
@@ -20,12 +20,8 @@ from web_interface import widget_bundle
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Will be initialized when blueprint is registered # The managers live on the blueprint object: app.py sets
config_manager = None # pages_v3.config_manager, pages_v3.plugin_manager and the rest.
plugin_manager = None
plugin_store_manager = None
schema_manager = None
pages_v3 = Blueprint('pages_v3', __name__) pages_v3 = Blueprint('pages_v3', __name__)
@@ -162,37 +158,8 @@ _SEARCH_INDEX_CACHE = {'sig': None, 'fields': None}
@pages_v3.route('/') @pages_v3.route('/')
def index(): def index():
"""Main v3 interface page""" """Main v3 interface page: the app shell. Every tab loads as a partial."""
try: return render_template('v3/base.html')
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)
@pages_v3.route('/partials/<partial_name>') @pages_v3.route('/partials/<partial_name>')
def load_partial(partial_name): def load_partial(partial_name):
-26
View File
@@ -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) logger.warning(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra)
else: else:
logger.info(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra) 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)
-17
View File
@@ -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
+1 -187
View File
@@ -1,30 +1,7 @@
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */ /* global showNotification */
// LED Matrix v3 JavaScript // LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration // 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 // HTMX response handlers
document.body.addEventListener('htmx:beforeRequest', function(event) { document.body.addEventListener('htmx:beforeRequest', function(event) {
// Show loading states for buttons // Show loading states for buttons
@@ -191,37 +168,6 @@ document.addEventListener('DOMContentLoaded', function() {
// SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in // SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in
// js/app-shell.js — do not open EventSources for stats/display here. // 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 // Auto-resize textareas
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const textareas = document.querySelectorAll('textarea'); 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 // Error handling for unhandled promise rejections
window.addEventListener('unhandledrejection', function(event) { window.addEventListener('unhandledrejection', function(event) {
console.error('Unhandled promise rejection:', event.reason); console.error('Unhandled promise rejection:', event.reason);
showNotification('An unexpected error occurred', 'error'); 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 ===== // ===== Floating live preview =====
// A mini preview of the display, available on every tab except Overview // A mini preview of the display, available on every tab except Overview
// (which has the full-size one). Open/closed state persists per browser; // (which has the full-size one). Open/closed state persists per browser;
-17
View File
@@ -2572,23 +2572,6 @@
// Assign to window for global access // Assign to window for global access
window.uninstallPlugin = uninstallPlugin; 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 // Format commit information for display
function formatCommitInfo(commit, branch) { function formatCommitInfo(commit, branch) {
if (!commit && !branch) return 'Unknown'; if (!commit && !branch) return 'Unknown';
@@ -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(`
<div class="mb-4">
<h4 class="text-sm font-semibold text-green-800 mb-2">
<i class="fas fa-plus-circle mr-1"></i>Added
</h4>
<div class="bg-green-50 border border-green-200 rounded p-3">
${Object.entries(differences.added).map(([key, value]) => `
<div class="mb-2">
<code class="text-xs font-mono bg-green-100 px-1 py-0.5 rounded">${this.escapeHtml(key)}</code>
<span class="text-sm text-gray-700 ml-2">=</span>
<pre class="mt-1 text-xs bg-white p-2 rounded border border-green-200 overflow-auto">${this.escapeHtml(JSON.stringify(value, null, 2))}</pre>
</div>
`).join('')}
</div>
</div>
`);
}
// Removed keys
if (Object.keys(differences.removed).length > 0) {
parts.push(`
<div class="mb-4">
<h4 class="text-sm font-semibold text-red-800 mb-2">
<i class="fas fa-minus-circle mr-1"></i>Removed
</h4>
<div class="bg-red-50 border border-red-200 rounded p-3">
${Object.entries(differences.removed).map(([key, value]) => `
<div class="mb-2">
<code class="text-xs font-mono bg-red-100 px-1 py-0.5 rounded">${this.escapeHtml(key)}</code>
<span class="text-sm text-gray-700 ml-2">=</span>
<pre class="mt-1 text-xs bg-white p-2 rounded border border-red-200 overflow-auto line-through text-gray-500">${this.escapeHtml(JSON.stringify(value, null, 2))}</pre>
</div>
`).join('')}
</div>
</div>
`);
}
// Changed keys
if (Object.keys(differences.changed).length > 0) {
parts.push(`
<div class="mb-4">
<h4 class="text-sm font-semibold text-yellow-800 mb-2">
<i class="fas fa-edit mr-1"></i>Changed
</h4>
<div class="bg-yellow-50 border border-yellow-200 rounded p-3">
${Object.entries(differences.changed).map(([key, change]) => `
<div class="mb-3">
<code class="text-xs font-mono bg-yellow-100 px-1 py-0.5 rounded">${this.escapeHtml(key)}</code>
<div class="mt-2 grid grid-cols-2 gap-2">
<div>
<div class="text-xs font-medium text-red-700 mb-1">Old Value:</div>
<pre class="text-xs bg-white p-2 rounded border border-red-200 overflow-auto">${this.escapeHtml(JSON.stringify(change.old, null, 2))}</pre>
</div>
<div>
<div class="text-xs font-medium text-green-700 mb-1">New Value:</div>
<pre class="text-xs bg-white p-2 rounded border border-green-200 overflow-auto">${this.escapeHtml(JSON.stringify(change.new, null, 2))}</pre>
</div>
</div>
</div>
`).join('')}
</div>
</div>
`);
}
if (parts.length === 0) {
return '<div class="text-sm text-gray-600 text-center py-4">No changes detected</div>';
}
return parts.join('');
},
/**
* Show diff modal before saving.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} newConfig - New configuration
* @returns {Promise<boolean>} 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 = `
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" data-diff-backdrop aria-hidden="true"></div>
<div id="config-diff-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full" style="position:relative;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
<i class="fas fa-code-branch text-blue-600"></i>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 id="config-diff-modal-title" class="text-lg leading-6 font-medium text-gray-900">Review Configuration Changes</h3>
<div class="mt-4 max-h-96 overflow-y-auto" tabindex="0" role="region" aria-label="Changes to save">
${this.formatDiff(differences)}
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" id="config-diff-confirm-btn"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
<i class="fas fa-check mr-2" aria-hidden="true"></i>Save Changes
</button>
<button type="button" id="config-diff-cancel-btn"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
`;
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;
}
-61
View File
@@ -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 // Handle HTMX errors gracefully with detailed logging
document.body.addEventListener('htmx:responseError', function(event) { document.body.addEventListener('htmx:responseError', function(event) {
const detail = event.detail; const detail = event.detail;
-356
View File
@@ -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;
}
})();
@@ -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<Array>} 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<Array>} 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<Object>} 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;
}
@@ -32,7 +32,7 @@
version: '2.0.0', version: '2.0.0',
render: function(container, config, value, options) { 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) { getValue: function(fieldId) {
@@ -918,6 +918,4 @@
} else { } else {
initArrayTableButtons(); initArrayTableButtons();
} }
console.log('[ArrayTableWidget] Array table widget registered (v2.0.0)');
})(); })();
@@ -201,6 +201,4 @@
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.BaseWidget = BaseWidget; window.BaseWidget = BaseWidget;
} }
console.log('[BaseWidget] Base widget class loaded');
})(); })();
@@ -31,7 +31,7 @@
render: function(container, config, value, options) { render: function(container, config, value, options) {
// For now, widgets are server-side rendered // For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering // 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); hiddenInput.dispatchEvent(event);
}; };
console.log('[CheckboxGroupWidget] Checkbox group widget registered');
})(); })();
@@ -258,6 +258,4 @@
} }
} }
}); });
console.log('[ColorPickerWidget] Color picker widget registered');
})(); })();
@@ -31,7 +31,7 @@
render: function(container, config, value, options) { render: function(container, config, value, options) {
// For now, widgets are server-side rendered // For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering // 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 = ''; event.target.value = '';
}); });
}; };
console.log('[CustomFeedsWidget] Custom feeds widget registered');
})(); })();
@@ -189,6 +189,4 @@
} }
} }
}); });
console.log('[DatePickerWidget] Date picker widget registered');
})(); })();
@@ -254,6 +254,4 @@
// Expose DAYS constant for external use // Expose DAYS constant for external use
window.LEDMatrixWidgets.get('day-selector').DAYS = DAYS; window.LEDMatrixWidgets.get('day-selector').DAYS = DAYS;
window.LEDMatrixWidgets.get('day-selector').DAY_LABELS = DAY_LABELS; window.LEDMatrixWidgets.get('day-selector').DAY_LABELS = DAY_LABELS;
console.log('[DaySelectorWidget] Day selector widget registered');
})(); })();
@@ -167,6 +167,4 @@
} }
} }
}); });
console.log('[EmailInputWidget] Email input widget registered');
})(); })();
@@ -197,6 +197,4 @@
} }
} }
}); });
console.log('[ColorPickerWidget] Color picker widget registered (example)');
})(); })();
@@ -286,6 +286,4 @@
} }
} }
}); });
console.log('[FileUploadSingleWidget] File upload single widget registered');
})(); })();
@@ -32,7 +32,7 @@
render: function(container, config, value, options) { render: function(container, config, value, options) {
// For now, widgets are server-side rendered // For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering // 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); window.updateImageList(fieldId, currentImages);
} }
}; };
console.log('[FileUploadWidget] File upload widget registered');
})(); })();
@@ -311,6 +311,4 @@
generateDisplayName: generateDisplayName generateDisplayName: generateDisplayName
} }
}); });
console.log('[FontSelectorWidget] Font selector widget registered');
})(); })();
@@ -192,6 +192,4 @@
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
console.log('[GoogleCalendarPickerWidget] registered');
})(); })();
@@ -828,8 +828,5 @@
getValue() { return null; }, getValue() { return null; },
setValue() {} setValue() {}
}); });
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
} else {
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
} }
})(); })();
@@ -472,6 +472,4 @@
} else { } else {
flushPending(); flushPending();
} }
console.log('[NotificationWidget] Notification widget registered');
})(); })();
@@ -239,6 +239,4 @@
} }
} }
}); });
console.log('[NumberInputWidget] Number input widget registered');
})(); })();
@@ -316,6 +316,4 @@
} }
} }
}); });
console.log('[PasswordInputWidget] Password input widget registered');
})(); })();
@@ -866,6 +866,4 @@
getValue: function () { return null; }, // file ops are immediate; nothing to submit getValue: function () { return null; }, // file ops are immediate; nothing to submit
setValue: function (fieldId) { loadFiles(fieldId); } setValue: function (fieldId) { loadFiles(fieldId); }
}); });
console.log('[PluginFileManager] plugin-file-manager widget registered');
})(); })();
@@ -29,7 +29,7 @@
// Check if widget is already registered // Check if widget is already registered
if (this.has(widgetName)) { if (this.has(widgetName)) {
console.log(`[PluginWidgetLoader] Widget ${widgetName} already registered`); if (window.debugLog) window.debugLog(`[PluginWidgetLoader] Widget ${widgetName} already registered`);
return; return;
} }
@@ -45,7 +45,7 @@
try { try {
// Dynamic import of plugin widget // Dynamic import of plugin widget
await import(widgetPath); 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 // Verify widget was registered
if (this.has(widgetName)) { if (this.has(widgetName)) {
@@ -124,6 +124,4 @@
return loadedWidgets; return loadedWidgets;
}; };
console.log('[PluginWidgetLoader] Plugin widget loader initialized');
})(); })();
@@ -144,6 +144,4 @@
} }
} }
}); });
console.log('[RadioGroupWidget] Radio group widget registered');
})(); })();
@@ -50,7 +50,7 @@
this._handlers.set(widgetName, definition.handlers); this._handlers.set(widgetName, definition.handlers);
} }
console.log(`[WidgetRegistry] Registered widget: ${widgetName}`); if (window.debugLog) window.debugLog(`[WidgetRegistry] Registered widget: ${widgetName}`);
return true; return true;
}, },
@@ -212,6 +212,4 @@
}))); })));
}; };
} }
console.log('[WidgetRegistry] Widget registry initialized');
})(); })();
@@ -128,6 +128,4 @@
} }
} }
}); });
console.log('[SelectDropdownWidget] Select dropdown widget registered');
})(); })();
@@ -173,6 +173,4 @@
} }
} }
}); });
console.log('[SliderWidget] Slider widget registered');
})(); })();
@@ -768,6 +768,4 @@
return null; return null;
} }
}); });
console.log('[StyleEditor] widget registered');
})(); })();
@@ -239,6 +239,4 @@
} }
} }
}); });
console.log('[TextInputWidget] Text input widget registered');
})(); })();
@@ -175,6 +175,4 @@
} }
} }
}); });
console.log('[TextareaWidget] Textarea widget registered');
})(); })();
@@ -166,6 +166,4 @@
} }
} }
}); });
console.log('[TimePickerWidget] Time picker widget registered');
})(); })();
@@ -370,6 +370,4 @@
// Expose utility functions for external use // Expose utility functions for external use
window.LEDMatrixWidgets.get('time-range').parseTimeToMinutes = parseTimeToMinutes; window.LEDMatrixWidgets.get('time-range').parseTimeToMinutes = parseTimeToMinutes;
window.LEDMatrixWidgets.get('time-range').calculateDuration = calculateDuration; window.LEDMatrixWidgets.get('time-range').calculateDuration = calculateDuration;
console.log('[TimeRangeWidget] Time range widget registered');
})(); })();
@@ -414,6 +414,4 @@
}, 50); }, 50);
}); });
})(); })();
console.log('[TimezoneSelectorWidget] Timezone selector widget registered');
})(); })();
@@ -220,6 +220,4 @@
} }
} }
}); });
console.log('[ToggleSwitchWidget] Toggle switch widget registered');
})(); })();
@@ -284,6 +284,4 @@
} }
} }
}); });
console.log('[UrlInputWidget] URL input widget registered');
})(); })();
-465
View File
@@ -41,164 +41,6 @@ const safeLocalStorage = {
const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true'; const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true';
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...'); 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 // Define configurePlugin early to ensure it's always available
window.configurePlugin = window.configurePlugin || async function(pluginId) { window.configurePlugin = window.configurePlugin || async function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] configurePlugin called for', 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; window.openOnDemandModal = window.__openOnDemandModalImpl;
// Release handle for the on-demand modal's focus trap (window.LEDDialog). // Release handle for the on-demand modal's focus trap (window.LEDDialog).
@@ -2260,8 +2101,6 @@ function stopOnDemand(event) {
requestOnDemandStop({ stopService }); requestOnDemandStop({ stopService });
} }
// Store the real implementation and replace the stub
window.__requestOnDemandStopImpl = requestOnDemandStop;
window.requestOnDemandStop = requestOnDemandStop; window.requestOnDemandStop = requestOnDemandStop;
function closeOnDemandModalOnBackdrop(event) { function closeOnDemandModalOnBackdrop(event) {
@@ -2518,98 +2357,6 @@ window.updateKeyValuePairData = function(fieldId, fullKey) {
hiddenInput.value = JSON.stringify(pairs); 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) { window.updateArrayObjectData = function(fieldId) {
const itemsContainer = document.getElementById(fieldId + '_items'); const itemsContainer = document.getElementById(fieldId + '_items');
const hiddenInput = document.getElementById(fieldId + '_data'); 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 // 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) { window.uninstallPlugin = function(pluginId) {
const plugin = (window.installedPlugins || installedPlugins || []).find(p => p.id === pluginId); const plugin = (window.installedPlugins || installedPlugins || []).find(p => p.id === pluginId);
const pluginName = plugin ? (plugin.name || pluginId) : pluginId; const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
@@ -4529,34 +4204,6 @@ function jsStringAttr(value) {
return escapeAttribute(JSON.stringify(value == null ? '' : String(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) { function isNewPlugin(lastUpdated) {
if (!lastUpdated) return false; 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 // 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) { window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) {
const fileTypeLabel = fileType === 'json' ? 'file' : 'image'; const fileTypeLabel = fileType === 'json' ? 'file' : 'image';
if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) { 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. // getUploadConfig is defined in file-upload.js widget which loads first.
// No override needed here — file-upload.js owns this function. // 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) { window.updateImageList = function(fieldId, images) {
const hiddenInput = document.getElementById(`${fieldId}_images_data`); const hiddenInput = document.getElementById(`${fieldId}_images_data`);
if (hiddenInput) { 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 = `
<i class="fas fa-spinner fa-spin text-3xl text-blue-500 mb-2"></i>
<p class="text-sm text-gray-600">Uploading ${totalFiles} file(s)...</p>
`;
dropZone.style.pointerEvents = 'none';
}
}
window.hideUploadProgress = function(fieldId) { window.hideUploadProgress = function(fieldId) {
const uploadConfig = window.getUploadConfig(fieldId); const uploadConfig = window.getUploadConfig(fieldId);
const maxFiles = uploadConfig.max_files || 10; 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) { function formatDate(dateString) {
if (!dateString) return 'Unknown date'; if (!dateString) return 'Unknown date';
try { 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) { window.openImageSchedule = function(fieldId, imageId, imageIdx) {
const currentImages = getCurrentImages(fieldId); const currentImages = getCurrentImages(fieldId);
const image = currentImages[imageIdx]; 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) { window.updateCheckboxGroupData = function(fieldId) {
// Update hidden _data input with currently checked values // Update hidden _data input with currently checked values
const hiddenInput = document.getElementById(fieldId + '_data'); const hiddenInput = document.getElementById(fieldId + '_data');
@@ -5542,22 +5122,6 @@ if (typeof window !== 'undefined') {
hiddenInput.value = JSON.stringify(selectedValues); 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) // Debug logging (only if pluginDebug is enabled)
if (_PLUGIN_DEBUG_EARLY) { if (_PLUGIN_DEBUG_EARLY) {
debugLog('[ARRAY-OBJECTS] Functions defined on window:', { debugLog('[ARRAY-OBJECTS] Functions defined on window:', {
@@ -5576,23 +5140,6 @@ window.currentPluginConfig = null;
// Force initialization immediately when script loads (for HTMX swapped content) // Force initialization immediately when script loads (for HTMX swapped content)
debugLog('Plugins script loaded, checking for elements...'); 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 // Verify critical functions are available
if (_PLUGIN_DEBUG_EARLY) { if (_PLUGIN_DEBUG_EARLY) {
debugLog('Plugin functions available:', { debugLog('Plugin functions available:', {
@@ -5610,18 +5157,6 @@ if (window.checkGitHubAuthStatus && document.getElementById('github-auth-warning
window.checkGitHubAuthStatus(); 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() { setTimeout(function() {
const installedGrid = document.getElementById('installed-plugins-grid'); const installedGrid = document.getElementById('installed-plugins-grid');
if (installedGrid) { if (installedGrid) {
+1 -8
View File
@@ -110,10 +110,8 @@
<script> <script>
(function() { (function() {
const htmxSrc = '/static/v3/js/htmx.min.js'; const htmxSrc = '/static/v3/js/htmx.min.js';
const sseSrc = '/static/v3/js/htmx-sse.js';
const jsonEncSrc = '/static/v3/js/htmx-json-enc.js'; const jsonEncSrc = '/static/v3/js/htmx-json-enc.js';
const htmxCdn = 'https://unpkg.com/htmx.org@1.9.10'; const htmxCdn = 'https://unpkg.com/htmx.org@1.9.10';
const sseCdn = 'https://unpkg.com/htmx.org@1.9.10/dist/ext/sse.js';
const jsonEncCdn = 'https://unpkg.com/htmx.org@1.9.10/dist/ext/json-enc.js'; const jsonEncCdn = 'https://unpkg.com/htmx.org@1.9.10/dist/ext/json-enc.js';
// Load a script with an optional one-shot fallback source // Load a script with an optional one-shot fallback source
@@ -138,8 +136,7 @@
function htmxReady() { function htmxReady() {
debugLog('HTMX loaded successfully'); debugLog('HTMX loaded successfully');
window.dispatchEvent(new Event('htmx:ready')); window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads // Load the json-enc extension after core loads
loadScript(sseSrc, sseCdn);
loadScript(jsonEncSrc, jsonEncCdn); loadScript(jsonEncSrc, jsonEncCdn);
} }
@@ -979,15 +976,11 @@
<script src="{{ url_for('static', filename='v3/js/plugins/api_client.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/js/plugins/api_client.js') }}" defer></script>
<!-- Load plugin management modules --> <!-- Load plugin management modules -->
<script src="{{ url_for('static', filename='v3/js/plugins/store_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
<!-- Shared search/filter/sort controller for the plugin card grids --> <!-- Shared search/filter/sort controller for the plugin card grids -->
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script>
<!-- Load config utilities -->
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
<!-- Widget Registry System: all widget scripts in one request (see <!-- Widget Registry System: all widget scripts in one request (see
web_interface/widget_bundle.py). The individual files stay served at web_interface/widget_bundle.py). The individual files stay served at
their own URLs for plugin-loader.js and debugging. --> their own URLs for plugin-loader.js and debugging. -->
-161
View File
@@ -1,161 +0,0 @@
{% extends "v3/base.html" %}
{% block content %}
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
<p class="mt-1 text-sm text-gray-600">Monitor system status and manage your LED matrix display.</p>
</div>
<!-- System Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-microchip text-blue-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">CPU Usage</dt>
<dd class="text-lg font-medium text-gray-900" id="cpu-usage">--%</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-memory text-green-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Memory Usage</dt>
<dd class="text-lg font-medium text-gray-900" id="memory-usage">--%</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-thermometer-half text-red-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">CPU Temperature</dt>
<dd class="text-lg font-medium text-gray-900" id="cpu-temp">--°C</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-desktop text-purple-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Display Status</dt>
<dd class="text-lg font-medium text-gray-900" id="display-status">Unknown</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="border-b border-gray-200 pb-4 mb-6">
<h3 class="text-md font-medium text-gray-900 mb-4">Quick Actions</h3>
<div class="flex flex-wrap gap-3" hx-ext="json-enc">
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i>
Start Display
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i>
Stop Display
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "git_pull"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i>
Update Code
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i>
Reboot System
</button>
</div>
</div>
<!-- Display Preview -->
<div>
<h3 class="text-md font-medium text-gray-900 mb-4">Display Preview</h3>
<div class="bg-gray-900 rounded-lg p-4 aspect-video flex items-center justify-center">
<div class="text-center text-gray-400">
<i class="fas fa-desktop text-4xl mb-2"></i>
<p>Display preview will appear here</p>
<p class="text-sm mt-1">Connect to see live updates</p>
</div>
</div>
</div>
</div>
<!-- Update stats via SSE -->
<script>
// Listen for system stats updates
if (typeof statsSource !== 'undefined') {
statsSource.onmessage = function(event) {
const data = JSON.parse(event.data);
// Update CPU
const cpuEl = document.getElementById('cpu-usage');
if (cpuEl && data.cpu_percent !== undefined) {
cpuEl.textContent = data.cpu_percent + '%';
}
// Update Memory
const memEl = document.getElementById('memory-usage');
if (memEl && data.memory_used_percent !== undefined) {
memEl.textContent = data.memory_used_percent + '%';
}
// Update Temperature
const tempEl = document.getElementById('cpu-temp');
if (tempEl && data.cpu_temp !== undefined) {
tempEl.textContent = data.cpu_temp + '°C';
}
// Update Display Status
const displayEl = document.getElementById('display-status');
if (displayEl) {
displayEl.textContent = data.service_active ? 'Active' : 'Inactive';
displayEl.className = data.service_active ?
'text-lg font-medium text-green-600' :
'text-lg font-medium text-red-600';
}
};
}
</script>
{% endblock %}
@@ -152,7 +152,7 @@ function initializeFontsTab() {
console.error('Fonts tab elements not found after max retries, giving up'); console.error('Fonts tab elements not found after max retries, giving up');
return; return;
} }
console.log('Fonts tab elements not found, retrying...', { debugLog('Fonts tab elements not found, retrying...', {
availableFonts: !!availableEl, availableFonts: !!availableEl,
attempt: initRetryCount attempt: initRetryCount
}); });
@@ -178,7 +178,7 @@ function initializeFontsTab() {
}; };
} }
console.log('Initializing font management...'); debugLog('Initializing font management...');
initializeFontManagement(); initializeFontManagement();
// Event listeners (use event delegation or ensure elements exist) // 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 // Expose initializeFontsTab to window for re-initialization after HTMX reload
@@ -244,7 +244,7 @@ window.initializeFontsTab = initializeFontsTab;
const fontsContent = document.getElementById('fonts-content'); const fontsContent = document.getElementById('fonts-content');
if (fontsContent) { if (fontsContent) {
console.log('Fonts content detected, initializing...'); debugLog('Fonts content detected, initializing...');
setTimeout(() => { setTimeout(() => {
initializeFontsTab(); initializeFontsTab();
}, 50); }, 50);
@@ -257,7 +257,7 @@ window.initializeFontsTab = initializeFontsTab;
// Check if the event target is the fonts-content container or contains it // Check if the event target is the fonts-content container or contains it
const target = event.target; const target = event.target;
if (target && (target.id === 'fonts-content' || target.querySelector && target.querySelector('#fonts-content'))) { 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(); tryInitializeFontsTab();
} }
}); });
@@ -334,7 +334,7 @@ async function loadFontData() {
// Update displays // Update displays
updateAvailableFontsDisplay(); updateAvailableFontsDisplay();
console.log('Font data loaded successfully', { debugLog('Font data loaded successfully', {
catalogSize: Object.keys(fontCatalog).length, catalogSize: Object.keys(fontCatalog).length,
tokensSize: Object.keys(fontTokens).length tokensSize: Object.keys(fontTokens).length
}); });
@@ -490,7 +490,7 @@ function populateFontSelects() {
previewSelect.value = fontEntries[0].filename; 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() { async function updateFontPreview() {
@@ -286,11 +286,11 @@ function wifiSetup() {
try { try {
const response = await fetch('/api/v3/wifi/scan'); const response = await fetch('/api/v3/wifi/scan');
const data = await response.json(); const data = await response.json();
console.log('WiFi scan response:', data); // Debug log debugLog('WiFi scan response:', data);
if (data.status === 'success') { if (data.status === 'success') {
// Ensure data.data is an array // Ensure data.data is an array
const networksArray = Array.isArray(data.data) ? data.data : []; 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 // Set the networks array - Alpine.js will automatically update the select dropdown via x-for
this.networks = networksArray; this.networks = networksArray;