Merge main into feat/plugin-composer, and fix three review findings

The branch was 57 commits behind and conflicting. I had put the rebase
aside earlier as needing the author's eyes, on the grounds that the PR is
+5091 lines -- but that was the wrong measure. The actual conflict was a
single hunk in app.css, where this branch adds .md\:inline and main added
.md\:block and .md\:w-auto at the same place. All three are kept.

Merging rather than rebasing: the branch is public and 57 commits behind,
so a rebase would rewrite shared history for a force-push.

Three findings fixed on top:

A missing `text` or `format` was a 500. `p` is a copy of the raw element
and the defaults were applied to the locals t1/fmt1 only, so an element
omitting either key left it absent, manager.py.j2 rendered
`{{ el.text | tojson }}` over a jinja2.Undefined, and tojson raised
TypeError -- which no handler catches:

    text without 'text':    TypeError: Object of type Undefined is not
                            JSON serializable
    clock without 'format': same

Both keys are now set explicitly. Verified: removing either assignment
fails 4 of the new tests.

E741 on my own injection-test file: two `for i, l in enumerate(...)`
loops, which ruff rejects and would fail a lint-gated build. Renamed.
Ruff now clean on all three files this PR touches.

Not done: registering composer_bp. This PR's own description gates it --
"Not yet wired up ... tracking as a follow-up", with an unchecked box for
"Register composer_bp in app.py before merging or exposing this route" --
so it is a deliberate decision, not an oversight. Confirmed the blueprint
appears in no register_blueprint call outside this branch's tests, which
also means the code-injection fixed earlier in this PR was never
reachable in a deployed instance. Worth fixing before the route is
exposed; not worth exposing the route to satisfy a review comment.

Verified on the merged tree: 3850 passed, 1 failed, 60 skipped. The
failure is test_install_lowmem's tmpfs assumption, which is fixed in #492
and not yet on main. The static audit now passes 3/3 -- the twelve
classes it flagged before were defined on main all along and only looked
missing because this branch was behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
This commit is contained in:
ChuckBuilds
2026-08-21 20:51:14 -04:00
co-authored by Claude Opus 5
368 changed files with 47239 additions and 14698 deletions
+6 -1
View File
@@ -30,7 +30,12 @@ web_interface/
└── static/ # CSS/JS assets
└── v3/
├── app.css
── app.js
── app.js
├── manifest.json # PWA manifest
├── plugins_manager.js
├── icons/ # PWA / touch icons
├── js/ # Alpine, htmx, app shell, widgets, utils
└── vendor/ # codemirror, fontawesome
```
## Running the Web Interface
+6 -1
View File
@@ -2,5 +2,10 @@
LED Matrix Web Interface V3
Modern web interface for controlling the LED Matrix display
"""
__version__ = "3.0.0"
# Re-exported, never hardcoded. This used to carry its own "3.0.0", a third
# answer to "what version is this" alongside the tag and src.__version__ —
# and disagreeing version numbers are what made plugin compatibility floors
# untrustworthy (see docs/SPORTS_UNIFICATION.md, phase B4).
from src import __version__ # noqa: F401
+82 -31
View File
@@ -16,6 +16,8 @@ from datetime import datetime, timedelta
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config_manager import ConfigManager
from src.web_interface.error_handler import describe_exception
from werkzeug.exceptions import HTTPException
from src.exceptions import ConfigError
from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.store_manager import PluginStoreManager
@@ -59,6 +61,20 @@ except ImportError:
# flask-limiter not installed, rate limiting disabled
limiter = None
# Enable gzip/brotli response compression (Flask-Compress skips streaming
# responses, so the SSE endpoints are unaffected). Optional, like limiter:
# missing package just means uncompressed responses.
try:
from flask_compress import Compress
Compress(app)
except ImportError:
logging.getLogger(__name__).warning(
"flask-compress not installed - responses will be served uncompressed. "
"Install it with the Tools tab's 'Install Base Requirements' button or "
"'pip install flask-compress'."
)
# Import cache functions from separate module to avoid circular imports
# Initialize plugin managers - read plugins directory from config
@@ -102,7 +118,8 @@ saved_repositories_manager = SavedRepositoriesManager()
schema_manager = SchemaManager(
plugins_dir=plugins_dir,
project_root=project_root,
logger=None
logger=None,
config_manager=config_manager
)
# Initialize operation queue for plugin operations
@@ -176,7 +193,12 @@ except Exception as _hm_err: # pragma: no cover - defensive startup guard
"Could not enable plugin health/metrics for web UI: %s", _hm_err
)
app.register_blueprint(pages_v3, url_prefix='/v3')
# Pages are served un-prefixed (the interface lives at /); the /v3 mount is a
# legacy alias kept so existing bookmarks and the hardcoded /v3/partials/...
# fetches in templates/JS keep working unchanged. url_for('pages_v3.*')
# resolves against the primary (un-prefixed) registration.
app.register_blueprint(pages_v3, url_prefix='')
app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy')
app.register_blueprint(api_v3, url_prefix='/api/v3')
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
@@ -372,15 +394,42 @@ def internal_error(error):
import logging
logger = logging.getLogger('web_interface')
logger.error("Internal server error", exc_info=True)
return jsonify({
payload = {
'status': 'error',
'error_code': 'INTERNAL_ERROR',
'message': 'An internal error occurred; see logs for details',
}), 500
}
# Flask hands the original exception over as `error.original_exception`
# when propagation is off; without it there is nothing to describe.
original = getattr(error, 'original_exception', None) or (
error if isinstance(error, BaseException) else None)
if original is not None:
payload['details'] = describe_exception(original)
return jsonify(payload), 500
@app.errorhandler(Exception)
def handle_exception(error):
"""Handle all unhandled exceptions."""
"""Handle all unhandled exceptions.
Returning only "see logs for details" is fine until the logs are exactly
what you cannot reach. A device with failing storage answered every
endpoint with that sentence -- including the log viewer, because journalctl
could not be executed -- while the exception underneath said
`[Errno 5] Input/output error`. Naming the error costs nothing here and is
frequently the whole diagnosis, so include it alongside the log pointer.
"""
# Werkzeug's HTTPExceptions subclass Exception, so this catch-all sees
# them too and was reporting every 405, 400, 413 and 415 as a server-side
# UNKNOWN_ERROR 500. A GET on a POST-only route came back as "an error
# occurred" rather than "method not allowed", which tells the caller
# nothing and blames the wrong side. Hand those back as themselves.
if isinstance(error, HTTPException):
return jsonify({
'status': 'error',
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
'message': error.description,
}), error.code or 500
import logging
logger = logging.getLogger('web_interface')
logger.error("Unhandled exception", exc_info=True)
@@ -388,6 +437,7 @@ def handle_exception(error):
'status': 'error',
'error_code': 'UNKNOWN_ERROR',
'message': 'An error occurred; see logs for details',
'details': describe_exception(error),
}), 500
# Captive portal redirect middleware
@@ -407,7 +457,11 @@ def captive_portal_redirect():
# List of paths that should NOT be redirected (allow normal operation)
allowed_paths = [
'/v3', # Main interface and all sub-paths (includes /v3/setup)
'/v3', # Legacy-prefixed interface and all sub-paths
'/setup', # Captive setup page itself (un-prefixed mount)
'/partials/', # HTMX partials (un-prefixed mount)
'/settings/', # Settings search index (un-prefixed mount)
'/plugin-ui/', # Plugin-provided web UI assets (un-prefixed mount)
'/api/v3/', # All API endpoints
'/static/', # Static files (CSS, JS, images)
'/hotspot-detect.html', # iOS/macOS detection
@@ -606,8 +660,6 @@ def system_status_generator():
def display_preview_generator():
"""Generate display preview updates from snapshot file"""
import base64
from PIL import Image
import io
snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path matches display_manager; only read here
# Viewer marker: this generator only runs while the broadcaster has
@@ -649,24 +701,26 @@ def display_preview_generator():
# Only read if file is new or has been updated
if last_modified is None or current_modified > last_modified:
try:
# Read and encode the image
with Image.open(snapshot_path) as img:
# Convert to PNG and encode as base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
preview_data = {
'timestamp': time.time(),
'width': width,
'height': height,
'image': img_str
}
last_modified = current_modified
yield preview_data
except Exception: # nosec B110 - SSE preview file may be mid-write; transient error, skip this update
# File might be being written, skip this update
pass
# The snapshot is already a PNG, written atomically by
# the display service (tmp + os.replace in
# display_manager), so pass the raw bytes straight
# through instead of PIL-decoding and re-encoding —
# identical payload, much less CPU on the Pi.
with open(snapshot_path, 'rb') as f:
img_str = base64.b64encode(f.read()).decode('utf-8')
preview_data = {
'timestamp': time.time(),
'width': width,
'height': height,
'image': img_str
}
last_modified = current_modified
yield preview_data
except OSError:
# Transient filesystem race (file rotated/replaced
# between mtime check and read); skip this update.
app.logger.debug("Preview snapshot read failed; skipping frame", exc_info=True)
else:
# No snapshot available
yield {
@@ -799,11 +853,8 @@ if limiter:
limiter.limit("200 per minute")(stream_display)
limiter.limit("200 per minute")(stream_logs)
# Main route - redirect to v3 interface as default
@app.route('/')
def index():
"""Redirect to v3 interface"""
return redirect(url_for('pages_v3.index'))
# The pages blueprint's index now serves '/' directly (see the un-prefixed
# blueprint registration above), so no redirect route is needed here.
@app.route('/favicon.ico')
def favicon():
File diff suppressed because it is too large Load Diff
+7
View File
@@ -251,6 +251,12 @@ def _preprocess_elements(elements: list) -> list:
if t == 'text':
t1 = el.get('text', '') or ''
t2 = el.get('text2', '') or ''
# p is a copy of the raw element, so a payload omitting these
# leaves the key absent and the template renders
# {{ el.text | tojson }} over a jinja2.Undefined. tojson then
# raises TypeError, which no handler catches -- so a missing
# key came back as a 500 rather than a validation error.
p['text'] = t1
p['text2'] = t2
# Detect {variable} tokens — generate format_map() call instead of literal
_var_re = re.compile(r'\{([a-zA-Z_]\w*)\}')
@@ -260,6 +266,7 @@ def _preprocess_elements(elements: list) -> list:
p['x2_expr'] = p['x_expr'] # second line uses same x
else: # clock
fmt1 = el.get('format', '%H:%M') or '%H:%M'
p['format'] = fmt1
fmt2 = el.get('format2', '') or ''
p['format2'] = fmt2
ref_len = max(len(fmt1), len(fmt2)) if fmt2 else len(fmt1)
+41 -28
View File
@@ -180,10 +180,6 @@ def load_partial(partial_name):
return _load_durations_partial()
elif partial_name == 'schedule':
return _load_schedule_partial()
elif partial_name == 'weather':
return _load_weather_partial()
elif partial_name == 'stocks':
return _load_stocks_partial()
elif partial_name == 'plugins':
return _load_plugins_partial()
elif partial_name == 'fonts':
@@ -397,12 +393,51 @@ def _load_display_partial():
return "Error loading partial", 500
def _load_durations_partial():
"""Load display durations partial"""
"""Load rotation & durations partial.
Builds one duration entry per display mode of every enabled plugin
(falling back to the display controller's 30s default), overlaid with any
values saved in display.display_durations. Historically the template only
looped over saved keys, and nothing ever populated them, so the page
rendered empty.
"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
duration_groups = []
covered_keys = set()
if pages_v3.plugin_manager:
try:
pages_v3.plugin_manager.discover_plugins()
saved = (main_config.get('display', {}) or {}).get('display_durations', {}) or {}
infos = sorted(pages_v3.plugin_manager.get_all_plugin_info(),
key=lambda i: (i.get('name') or i.get('id') or '').lower())
for info in infos:
pid = info.get('id')
if not pid or not (main_config.get(pid, {}) or {}).get('enabled', False):
continue
modes = pages_v3.plugin_manager.get_plugin_display_modes(pid) or [pid]
covered_keys.update(modes)
duration_groups.append({
'plugin_id': pid,
'plugin_name': info.get('name') or pid,
'modes': [{'key': m, 'value': saved.get(m, 30)} for m in modes],
})
# Saved keys not owned by any enabled plugin (disabled or
# uninstalled plugins) stay visible rather than vanishing.
leftovers = [{'key': k, 'value': v} for k, v in saved.items()
if k not in covered_keys]
if leftovers:
duration_groups.append({
'plugin_id': '',
'plugin_name': 'Other saved entries',
'modes': leftovers,
})
except Exception:
logger.warning("durations: could not enumerate plugin modes", exc_info=True)
return render_template('v3/partials/durations.html',
main_config=main_config)
main_config=main_config,
duration_groups=duration_groups)
except Exception as e:
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
@@ -425,28 +460,6 @@ def _load_schedule_partial():
return "Error loading partial", 500
def _load_weather_partial():
"""Load weather configuration partial"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
return render_template('v3/partials/weather.html',
main_config=main_config)
except Exception as e:
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_stocks_partial():
"""Load stocks configuration partial"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
return render_template('v3/partials/stocks.html',
main_config=main_config)
except Exception as e:
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_plugins_partial():
"""Load plugins management partial"""
try:
+6
View File
@@ -29,6 +29,12 @@ def set_cached(key: str, value: Any, ttl_seconds: int = 60) -> None:
_cache_timestamps[key] = time.time()
def delete_cached(key: str) -> None:
"""Remove a single key from the cache if present."""
_cache.pop(key, None)
_cache_timestamps.pop(key, None)
def invalidate_cache(pattern: Optional[str] = None) -> None:
"""Invalidate cache entries matching pattern, or all if pattern is None."""
if pattern is None:
+14 -28
View File
@@ -4,19 +4,16 @@
# Web framework
flask>=3.1.3,<4.0.0
werkzeug>=3.1.6,<4.0.0
flask-wtf>=1.2.0 # CSRF protection (optional for local-only, but recommended)
flask-limiter>=3.5.0 # Rate limiting (prevent accidental abuse)
werkzeug>=3.1.6,<4.0.0 # Flask transitive; pinned to keep a security floor above Flask's own >=3.1.0
flask-limiter>=3.5.0,<4.0.0 # Rate limiting (prevent accidental abuse)
flask-compress>=1.14 # gzip/brotli response compression (big win for the large JS/HTML over WiFi)
jinja2>=3.1.6,<4.0.0 # Flask transitive, but imported directly (TemplateNotFound); 3.1.6 is the security floor — earlier 3.1.x has sandbox breakouts
markupsafe>=2.1.0,<4.0.0 # Flask transitive, but imported directly (escape)
# WebSocket support for plugins
# Note: Web interface uses Server-Sent Events (SSE) for real-time updates, not WebSockets
# However, plugins may need websocket support to connect to external services
# (e.g., music plugin connecting to YTM Companion server via Socket.IO)
# These packages are required for plugin compatibility
python-socketio>=5.14.0,<6.0.0
python-engineio>=4.9.0,<5.0.0
websockets>=12.0,<14.0
websocket-client>=1.8.0,<2.0.0
# WebSocket support: intentionally NOT declared here. The web interface
# uses Server-Sent Events, and plugins that need Socket.IO (e.g.
# ledmatrix-music) declare it in their own requirements.txt, which the
# plugin store installs.
# Image processing
Pillow>=12.2.0,<13.0.0
@@ -25,7 +22,7 @@ Pillow>=12.2.0,<13.0.0
psutil>=6.0.0,<7.0.0
# Font rendering
freetype-py>=2.5.0,<3.0.0
freetype-py>=2.5.1,<3.0.0
# Numerical operations
# NumPy 1.24+ required for Python 3.12+ compatibility (compatible with 2.x)
@@ -34,24 +31,13 @@ numpy>=1.24.0
# HTTP requests
requests>=2.33.0,<3.0.0
# Date/time utilities
python-dateutil>=2.9.0,<3.0.0
# Timezone handling (must match main requirements)
pytz>=2024.2,<2025.0
timezonefinder>=6.5.0,<7.0.0
geopy>=2.4.1,<3.0.0
# Google API integration (must match main requirements)
google-auth-oauthlib>=1.2.0,<2.0.0
google-auth-httplib2>=0.2.0,<1.0.0
google-api-python-client>=2.147.0,<3.0.0
# Spotify integration (must match main requirements)
spotipy>=2.25.2,<3.0.0
# Text processing (must match main requirements)
unidecode>=1.3.8,<2.0.0
# Calendar integration (must match main requirements)
icalevents>=0.1.27,<1.0.0
# Plugin-era note: timezonefinder, geopy, the google-api client stack,
# unidecode, icalevents and python-dateutil used to live here for the
# built-in weather/calendar/music displays. Those are store plugins now
# and declare their own dependencies, which the plugin store installs.
+192
View File
@@ -413,6 +413,9 @@ a, button, input, select, textarea {
/* Responsive breakpoints */
@media (min-width: 640px) {
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.sm\:block { display: block; }
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; }
}
@media (min-width: 768px) {
@@ -421,6 +424,8 @@ a, button, input, select, textarea {
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.md\:flex { display: flex; }
.md\:hidden { display: none; }
.md\:block { display: block; }
.md\:w-auto { width: auto; }
/* composer.html labels its toolbar buttons `hidden md:inline`, so without
this the label is hidden at every width and the buttons stay icon-only. */
.md\:inline { display: inline; }
@@ -434,9 +439,14 @@ a, button, input, select, textarea {
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
.lg\:gap-x-3 { column-gap: 0.75rem; }
.lg\:gap-x-6 { column-gap: 1.5rem; }
.lg\:block { display: block; }
.lg\:flex { display: flex; }
.lg\:w-64 { width: 16rem; }
}
@media (min-width: 1280px) {
.xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
@@ -449,6 +459,9 @@ a, button, input, select, textarea {
}
@media (min-width: 1536px) {
.2xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.2xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.2xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.2xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.2xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.2xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
@@ -459,6 +472,129 @@ a, button, input, select, textarea {
.2xl\:space-x-8 > * + * { margin-left: 2rem; }
}
/* ===== Mobile navigation drawer =====
Below md the #site-nav wrapper becomes an off-canvas drawer; at md and up
none of these rules apply and the nav renders exactly as before. */
@media (max-width: 767.98px) {
.site-nav {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 60;
width: min(85vw, 320px);
background-color: var(--color-surface);
border-right: 1px solid var(--color-border);
transform: translateX(-100%);
transition: transform 0.25s ease;
overflow-y: auto;
padding: 1rem;
-webkit-overflow-scrolling: touch;
}
.site-nav.open {
transform: translateX(0);
box-shadow: 0 0 24px rgba(0, 0, 0, 0.25);
}
/* Tabs become full-width rows with >=44px touch targets */
.site-nav .nav-tab {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
text-align: left;
padding: 0.75rem 1rem;
min-height: 44px;
}
.site-nav nav.-mb-px {
display: block;
}
.nav-backdrop {
position: fixed;
inset: 0;
z-index: 55;
background-color: rgba(0, 0, 0, 0.4);
}
/* Header widgets relocated into the drawer (see placeHeaderWidgets in
app.js). The originals carry `hidden`/breakpoint classes tuned for the
header, so re-enable them explicitly in the drawer context. */
#drawer-widgets #settings-search-wrap {
display: block !important;
margin-bottom: 1rem;
}
#drawer-widgets #settings-search-wrap input {
width: 100%;
}
#drawer-widgets #settings-search-results {
position: static;
width: 100%;
max-height: 50vh;
margin-top: 0.25rem;
}
#drawer-widgets #system-stats {
display: flex !important;
justify-content: space-between;
margin-bottom: 1rem;
}
/* Larger touch targets inside horizontally scrolling tables */
.overflow-x-auto table button {
min-width: 44px;
min-height: 44px;
}
.overflow-x-auto table input:not([type="checkbox"]),
.overflow-x-auto table select {
min-height: 40px;
}
.overflow-x-auto table input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
}
}
@media (min-width: 768px) {
/* Hard guards: even if mobileNavOpen was left true when the viewport
crossed the breakpoint, the drawer/backdrop must render as plain
in-flow nav on desktop. */
.site-nav {
position: static;
transform: none;
width: auto;
padding: 0;
border-right: none;
box-shadow: none;
background-color: transparent;
overflow-y: visible;
}
.nav-backdrop {
display: none !important;
}
#drawer-widgets {
display: none;
}
}
/* Mobile modal sizing: every .modal-content dialog fits the viewport with
internal scrolling instead of overflowing it. */
@media (max-width: 640px) {
.modal-content {
width: 95vw !important;
max-width: 95vw !important;
max-height: 90vh;
overflow-y: auto;
}
}
/* Edge-fade hint that a container scrolls horizontally (pure CSS,
Lea Verou scrolling-shadows technique backgrounds sit behind content). */
.overflow-x-auto {
background:
linear-gradient(90deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) left / 24px 100%,
linear-gradient(270deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) right / 24px 100%,
radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)) left / 12px 100%,
radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)) right / 12px 100%;
background-repeat: no-repeat;
background-attachment: local, local, scroll, scroll;
}
/* HTMX loading states */
.htmx-request .loading {
display: inline-block;
@@ -1201,6 +1337,21 @@ button.bg-white {
color: #93c5fd;
}
/* Installed-plugin "Update" button when a newer version is published.
A gentle pulsing ring draws the eye without being noisy. */
.plugin-update-available {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.6);
animation: plugin-update-pulse 2s ease-out infinite;
}
@keyframes plugin-update-pulse {
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.55); }
70% { box-shadow: 0 0 0 6px rgba(59, 130, 246, 0); }
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
}
@media (prefers-reduced-motion: reduce) {
.plugin-update-available { animation: none; }
}
/* Under-voltage / throttling warning banner */
.power-warning-banner {
background-color: #fef2f2;
@@ -1223,3 +1374,44 @@ button.bg-white {
[data-theme="dark"] .power-warning-banner-dismiss {
color: #fca5a5;
}
/* ===== Floating live preview (all tabs except Overview) ===== */
.floating-preview {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 70;
background-color: #111827;
border: 1px solid #374151;
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
/* Desktop: draggable resize handle (bottom-left visually, since the
panel is anchored to the right). Touch devices use the size button. */
resize: both;
min-width: 160px;
max-width: 90vw;
}
.floating-preview img {
background-color: #000;
}
.floating-preview-toggle {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 70;
width: 44px;
height: 44px;
border-radius: 9999px;
background-color: var(--color-primary);
color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
align-items: center;
justify-content: center;
}
@media (max-width: 640px) {
/* Whatever size is chosen, never wider than the phone viewport */
.floating-preview {
max-width: calc(100vw - 2rem);
}
}
+295 -9
View File
@@ -1,4 +1,4 @@
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */
// LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration
@@ -12,8 +12,8 @@ window.showNotification = function(message, type = 'info') {
});
document.dispatchEvent(event);
} else {
// Fallback notification
console.log(`${type}: ${message}`);
// Fallback notification — user-facing last resort, so never gated
console.info(`${type}: ${message}`);
}
};
@@ -49,6 +49,125 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
// Not JSON, ignore
}
}
// Main-config saves (display hardware, rotation/durations, general) only
// take effect after a display-service restart — surface the reminder
// banner. Plugin config saves apply live and are deliberately excluded.
try {
const cfg = event.detail.requestConfig;
if (cfg && cfg.verb === 'post' &&
(cfg.path || '').includes('/api/v3/config/main') &&
response && response.status >= 200 && response.status < 300) {
window.showRestartPending();
}
} catch { /* banner is best-effort */ }
});
// ===== Unsaved-changes guard =====
// Plugin config panels are Alpine x-if templates: navigating away DESTROYS
// the panel and revisiting re-fetches it, silently discarding any edits.
// (System tabs use x-show + data-loaded and persist, so they're exempt.)
// Track dirty forms and confirm before a lossy navigation.
(function() {
function markDirty(e) {
const form = e.target && e.target.closest ? e.target.closest('form') : null;
if (form) form.setAttribute('data-dirty', '');
}
document.body.addEventListener('input', markDirty);
document.body.addEventListener('change', markDirty);
// A successful submit makes the form clean again
document.body.addEventListener('htmx:afterRequest', function(event) {
const xhr = event.detail.xhr;
const form = event.detail.elt && event.detail.elt.closest ? event.detail.elt.closest('form') : null;
if (form && xhr && xhr.status >= 200 && xhr.status < 300) {
form.removeAttribute('data-dirty');
}
});
// Capture phase so this runs before Alpine's bubbling @click switches tabs
document.addEventListener('click', function(e) {
const tabBtn = e.target && e.target.closest ? e.target.closest('.nav-tab') : null;
if (!tabBtn) return;
const lossy = Array.prototype.filter.call(
document.querySelectorAll('.plugin-config-tab form[data-dirty]'),
function(f) { return f.offsetParent !== null; }
);
if (lossy.length === 0) return;
if (!window.confirm('You have unsaved plugin settings — leaving this page will discard them. Leave anyway?')) {
e.stopPropagation();
e.preventDefault();
}
}, true);
// Full page unload loses every panel's edits
window.addEventListener('beforeunload', function(e) {
const dirty = Array.prototype.some.call(
document.querySelectorAll('form[data-dirty]'),
function(f) { return f.offsetParent !== null; }
);
if (dirty) {
e.preventDefault();
e.returnValue = '';
}
});
})();
// ===== Restart-pending banner =====
// Shown after restart-requiring saves; persists across tab switches (and
// reloads, via sessionStorage) until the display restarts or it's dismissed.
window.showRestartPending = function(message) {
try {
sessionStorage.setItem('ledmatrix-restart-pending', '1');
// Persisted alongside the flag: a code update and a config save want
// different wording, and the banner outlives the page that raised it.
if (message) sessionStorage.setItem('ledmatrix-restart-pending-text', message);
else sessionStorage.removeItem('ledmatrix-restart-pending-text');
} catch { /* private browsing */ }
const banner = document.getElementById('restart-pending-banner');
const text = document.getElementById('restart-pending-text');
if (text && message) text.textContent = message;
if (banner) banner.style.display = 'block';
};
window.dismissRestartPending = function() {
try {
sessionStorage.removeItem('ledmatrix-restart-pending');
sessionStorage.removeItem('ledmatrix-restart-pending-text');
} catch { /* no-op */ }
const banner = document.getElementById('restart-pending-banner');
if (banner) banner.style.display = 'none';
};
window.restartPendingNow = function() {
const btn = document.getElementById('restart-pending-btn');
if (btn) btn.disabled = true;
fetch('/api/v3/system/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'restart_display_service' })
})
.then(r => r.json())
.then(data => {
showNotification(data.message || 'Display restarting…', data.status || 'success');
window.dismissRestartPending();
})
.catch(err => {
showNotification('Error restarting display: ' + err.message, 'error');
})
.finally(() => { if (btn) btn.disabled = false; });
};
document.addEventListener('DOMContentLoaded', function() {
try {
if (sessionStorage.getItem('ledmatrix-restart-pending') === '1') {
const banner = document.getElementById('restart-pending-banner');
const saved = sessionStorage.getItem('ledmatrix-restart-pending-text');
const text = document.getElementById('restart-pending-text');
if (text && saved) text.textContent = saved;
if (banner) banner.style.display = 'block';
}
} catch { /* no-op */ }
});
// SSE reconnection helper — closes and reopens both SSE streams,
@@ -246,13 +365,13 @@ window.performanceMonitor = {
logMetrics: function() {
const metrics = this.getMetrics();
console.group('Performance Metrics');
console.log('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
console.log('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
console.log('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
console.log('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
console.log('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
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) {
console.log('Custom Measures:', metrics.measures);
debugLog('Custom Measures:', metrics.measures);
}
console.groupEnd();
}
@@ -273,3 +392,170 @@ document.addEventListener('DOMContentLoaded', function() {
}, 100);
});
});
// ===== Floating live preview =====
// A mini preview of the display, available on every tab except Overview
// (which has the full-size one). Open/closed state persists per browser;
// frames arrive via the existing SSE stream (updateDisplayPreview in
// app-shell.js feeds #floating-preview-img).
window.toggleFloatingPreview = function(open) {
try { localStorage.setItem('ledmatrix-floating-preview', open ? '1' : '0'); } catch { /* no-op */ }
window.updateFloatingPreviewVisibility();
};
// Preset widths the size button cycles through (px). Desktop users can also
// drag the panel's native resize handle (CSS resize: both).
const FLOATING_PREVIEW_SIZES = [192, 256, 384, 512];
window.applyFloatingPreviewSize = function() {
const panel = document.getElementById('floating-preview');
if (!panel) return;
let size = 256;
try { size = parseInt(localStorage.getItem('ledmatrix-floating-preview-size'), 10) || 256; } catch { /* no-op */ }
panel.style.width = size + 'px';
// Clear any manual drag-resize height so the image's aspect ratio rules
panel.style.height = '';
};
window.cycleFloatingPreviewSize = function() {
let size = 256;
try { size = parseInt(localStorage.getItem('ledmatrix-floating-preview-size'), 10) || 256; } catch { /* no-op */ }
const idx = FLOATING_PREVIEW_SIZES.indexOf(size);
const next = FLOATING_PREVIEW_SIZES[(idx + 1) % FLOATING_PREVIEW_SIZES.length];
try { localStorage.setItem('ledmatrix-floating-preview-size', String(next)); } catch { /* no-op */ }
window.applyFloatingPreviewSize();
};
window.updateFloatingPreviewVisibility = function(tab) {
const panel = document.getElementById('floating-preview');
const toggle = document.getElementById('floating-preview-toggle');
if (!panel || !toggle) return;
let active = tab;
if (!active) {
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
const data = el && el._x_dataStack && el._x_dataStack[0];
active = data && data.activeTab;
}
const onOverview = active === 'overview';
let open = false;
try { open = localStorage.getItem('ledmatrix-floating-preview') === '1'; } catch { /* no-op */ }
const showPanel = !onOverview && open;
panel.style.display = showPanel ? 'block' : 'none';
toggle.style.display = (!onOverview && !open) ? 'flex' : 'none';
if (showPanel) {
window.applyFloatingPreviewSize();
// Show the last cached frame immediately — SSE only pushes on
// display changes, so a freshly opened panel would otherwise stay
// empty until the next change.
const img = document.getElementById('floating-preview-img');
if (img && !img.src && window._lastPreviewFrame) {
img.src = 'data:image/png;base64,' + window._lastPreviewFrame;
}
}
};
document.addEventListener('DOMContentLoaded', function() {
window.updateFloatingPreviewVisibility();
});
// Run a plugin on the real display for 60s via the existing on-demand API
// and open the floating preview so the effect is visible while configuring.
window.previewPluginNow = function(pluginId) {
fetch('/api/v3/display/on-demand/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId, duration: 60 })
})
.then(r => r.json())
.then(data => {
showNotification(data.message || ('Previewing ' + pluginId + ' for 60 seconds'),
data.status || 'success');
if (data.status === 'success') window.toggleFloatingPreview(true);
})
.catch(err => {
showNotification('Preview failed: ' + err.message, 'error');
});
};
// ===== Nav accessibility =====
// aria-current tracks the active tab. Buttons are matched by their Alpine
// @click expression ("activeTab = '<tab>'"), which works for both the static
// system tabs and the dynamically injected plugin tabs.
window.updateNavAriaCurrent = function(tab) {
document.querySelectorAll('.nav-tab').forEach(function(btn) {
const expr = btn.getAttribute('@click') || btn.getAttribute('x-on:click') || '';
const isCurrent = expr.indexOf("activeTab = '" + tab + "'") !== -1;
if (isCurrent) {
btn.setAttribute('aria-current', 'page');
} else {
btn.removeAttribute('aria-current');
}
});
};
// Escape closes the mobile nav drawer and returns focus to the hamburger;
// opening the drawer moves focus to its first tab.
(function() {
function appData() {
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
return el && el._x_dataStack && el._x_dataStack[0];
}
document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape') return;
const data = appData();
if (data && data.mobileNavOpen) {
data.mobileNavOpen = false;
const burger = document.querySelector('[aria-controls="site-nav"]');
if (burger) burger.focus();
}
});
document.addEventListener('click', function(e) {
const burger = e.target && e.target.closest
? e.target.closest('[aria-controls="site-nav"]') : null;
if (!burger) return;
// The click handler toggles mobileNavOpen; focus the first tab once
// the drawer has slid in (matches the CSS transition timing).
setTimeout(function() {
const data = appData();
if (data && data.mobileNavOpen) {
const first = document.querySelector('#site-nav .nav-tab');
if (first) first.focus();
}
}, 120);
});
})();
// ===== Mobile nav: header-widget relocation =====
// Below the md breakpoint the settings-search box and system-stats block are
// MOVED (same DOM nodes, listeners intact) from the header into the nav
// drawer's #drawer-widgets slot; at md and up they move back. Single-instance
// constraint: settings-search.js and the SSE stats updater both look these
// elements up by id, so they must never be duplicated.
window.placeHeaderWidgets = function() {
const drawer = document.getElementById('drawer-widgets');
const header = document.getElementById('header-widgets');
const search = document.getElementById('settings-search-wrap');
const stats = document.getElementById('system-stats');
if (!drawer || !header) return;
const desktop = window.matchMedia('(min-width: 768px)').matches;
if (desktop) {
// Restore original header order: search before the theme toggle,
// stats as the last item.
const themeToggle = document.getElementById('theme-toggle');
if (search && search.parentElement !== header) {
header.insertBefore(search, themeToggle || null);
}
if (stats && stats.parentElement !== header) {
header.appendChild(stats);
}
} else {
if (search && search.parentElement !== drawer) drawer.appendChild(search);
if (stats && stats.parentElement !== drawer) drawer.appendChild(stats);
}
};
document.addEventListener('DOMContentLoaded', function() {
window.placeHeaderWidgets();
window.matchMedia('(min-width: 768px)').addEventListener('change', window.placeHeaderWidgets);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+356
View File
@@ -0,0 +1,356 @@
/* global debugLog */
// Early helpers and the app() stub (must run before Alpine init)
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
// Helper function to get installed plugins with fallback
// Must be defined before app() function that uses it
async function getInstalledPluginsSafe() {
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
try {
const plugins = await window.PluginAPI.getInstalledPlugins();
// Ensure plugins is always an array
const pluginsArray = Array.isArray(plugins) ? plugins : [];
return { status: 'success', data: { plugins: pluginsArray } };
} catch (error) {
console.error('Error using PluginAPI.getInstalledPlugins, falling back to direct fetch:', error);
// Fall through to direct fetch
}
}
// Fallback to direct fetch if PluginAPI not loaded
const response = await fetch('/api/v3/plugins/installed');
return await response.json();
}
// Global event listener for pluginsUpdated - works even if Alpine isn't ready yet
// This ensures tabs update when plugins_manager.js loads plugins
document.addEventListener('pluginsUpdated', function(event) {
debugLog('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
const plugins = event.detail?.plugins || [];
// Update window.installedPlugins
window.installedPlugins = plugins;
// Try to update Alpine component if it exists (only if using full implementation)
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
const appComponent = appElement._x_dataStack[0];
appComponent.installedPlugins = plugins;
// Only call updatePluginTabs if it's the full implementation (has _doUpdatePluginTabs)
if (typeof appComponent.updatePluginTabs === 'function' &&
appComponent.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
debugLog('[GLOBAL] Updating plugin tabs via Alpine component (full implementation)');
appComponent.updatePluginTabs();
return; // Full implementation handles it, don't do direct update
}
}
}
// Only do direct DOM update if full implementation isn't available yet
const pluginTabsRow = document.getElementById('plugin-tabs-row');
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
// Clear existing plugin tabs (except Plugin Manager)
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
existingTabs.forEach(tab => { tab.remove(); });
// Add tabs for each installed plugin
plugins.forEach(plugin => {
const tabButton = document.createElement('button');
tabButton.type = 'button';
tabButton.setAttribute('data-plugin-id', plugin.id);
tabButton.className = `plugin-tab nav-tab`;
tabButton.onclick = function() {
// Try to set activeTab via Alpine if available
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
appElement._x_dataStack[0].activeTab = plugin.id;
// Only call updatePluginTabStates if it exists
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
appElement._x_dataStack[0].updatePluginTabStates();
}
}
}
};
// Built with DOM APIs (no innerHTML): the icon class and
// name come from plugin manifests, which are only
// semi-trusted input.
const tabIcon = document.createElement('i');
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
tabButton.textContent = '';
tabButton.appendChild(tabIcon);
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
pluginTabsNav.appendChild(tabButton);
});
debugLog('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
}
});
// Guard flag to prevent duplicate stub-to-full enhancement
window._appEnhanced = false;
// Define app() function early so Alpine can find it when it initializes
// This is a complete implementation that will work immediately
(function() {
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
// Create the app function - will be enhanced by full implementation later
window.app = function() {
return {
activeTab: isAPMode ? 'wifi' : 'overview',
mobileNavOpen: false,
installedPlugins: [],
init() {
// Try to enhance immediately with full implementation
const tryEnhance = () => {
if (window._appEnhanced) return true;
if (typeof window.app === 'function') {
const fullApp = window.app();
// Check if this is the full implementation (has updatePluginTabs with proper implementation)
if (fullApp && typeof fullApp.updatePluginTabs === 'function' && fullApp.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
window._appEnhanced = true;
// Preserve runtime state that should not be reset
const preservedPlugins = this.installedPlugins;
const preservedTab = this.activeTab;
const defaultTab = isAPMode ? 'wifi' : 'overview';
const wasInitialized = this._initialized;
Object.assign(this, fullApp);
// Restore runtime state if non-default
if (preservedPlugins && preservedPlugins.length > 0) {
this.installedPlugins = preservedPlugins;
}
if (preservedTab && preservedTab !== defaultTab) {
this.activeTab = preservedTab;
}
if (wasInitialized) {
this._initialized = wasInitialized;
}
// Only call init if not already initialized
if (typeof this.init === 'function' && !this._initialized) {
this.init();
}
return true;
}
}
return false;
};
// Set up event listener for pluginsUpdated in stub (only if not already enhanced)
// The full implementation will have its own listener, so we only need this for the stub
if (!this._pluginsUpdatedListenerSet) {
const handlePluginsUpdated = (event) => {
debugLog('[STUB] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
const plugins = event.detail?.plugins || [];
// Only update if we're still in stub mode (not enhanced yet)
if (typeof this.updatePluginTabs === 'function' && !this.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
this.installedPlugins = plugins;
if (this.$nextTick && typeof this.$nextTick === 'function') {
this.$nextTick(() => {
this.updatePluginTabs();
});
} else {
setTimeout(() => {
this.updatePluginTabs();
}, 100);
}
}
};
document.addEventListener('pluginsUpdated', handlePluginsUpdated);
this._pluginsUpdatedListenerSet = true;
debugLog('[STUB] init: Set up pluginsUpdated event listener');
}
// Try immediately - if full implementation is already loaded, use it right away
if (!tryEnhance()) {
// Full implementation not ready yet, load plugins directly while waiting
this.loadInstalledPluginsDirectly();
// Try again very soon to enhance with full implementation
setTimeout(tryEnhance, 10);
// Also set up a periodic check to update tabs if plugins get loaded by plugins_manager.js
let retryCount = 0;
const maxRetries = 20; // Check for 2 seconds (20 * 100ms)
const checkAndUpdateTabs = () => {
if (retryCount >= maxRetries) {
// Fallback: if plugins_manager.js hasn't loaded after 2 seconds, fetch directly
if (!window.installedPlugins || window.installedPlugins.length === 0) {
debugLog('[STUB] checkAndUpdateTabs: Fallback - fetching plugins directly after timeout');
this.loadInstalledPluginsDirectly();
}
return;
}
// Check if plugins are available (either from window or component)
const plugins = window.installedPlugins || this.installedPlugins || [];
if (plugins.length > 0) {
debugLog('[STUB] checkAndUpdateTabs: Found', plugins.length, 'plugins, updating tabs');
this.installedPlugins = plugins;
if (typeof this.updatePluginTabs === 'function') {
this.updatePluginTabs();
}
} else {
retryCount++;
setTimeout(checkAndUpdateTabs, 100);
}
};
// Start checking after a short delay
setTimeout(checkAndUpdateTabs, 200);
} else {
// Full implementation loaded, but still set up fallback timer
setTimeout(() => {
if (!window.installedPlugins || window.installedPlugins.length === 0) {
debugLog('[STUB] init: Fallback timer - fetching plugins directly');
this.loadInstalledPluginsDirectly();
}
}, 2000);
}
},
// Direct plugin loading for stub (before full implementation loads)
async loadInstalledPluginsDirectly() {
try {
debugLog('[STUB] loadInstalledPluginsDirectly: Starting...');
// Ensure DOM is ready
const ensureDOMReady = () => {
return new Promise((resolve) => {
if (document.readyState === 'complete' || document.readyState === 'interactive') {
// Use requestAnimationFrame to ensure DOM is painted
requestAnimationFrame(() => {
setTimeout(resolve, 50); // Small delay to ensure rendering
});
} else {
document.addEventListener('DOMContentLoaded', () => {
requestAnimationFrame(() => {
setTimeout(resolve, 50);
});
});
}
});
};
await ensureDOMReady();
const data = await getInstalledPluginsSafe();
if (data.status === 'success') {
const plugins = data.data.plugins || [];
debugLog('[STUB] loadInstalledPluginsDirectly: Loaded', plugins.length, 'plugins');
// Update both component and window
this.installedPlugins = plugins;
window.installedPlugins = plugins;
// Dispatch event so global listener can update tabs
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
detail: { plugins: plugins }
}));
debugLog('[STUB] loadInstalledPluginsDirectly: Dispatched pluginsUpdated event');
// Update tabs if we have the method - use $nextTick if available
if (typeof this.updatePluginTabs === 'function') {
if (this.$nextTick && typeof this.$nextTick === 'function') {
this.$nextTick(() => {
this.updatePluginTabs();
});
} else {
// Fallback: wait a bit for DOM
setTimeout(() => {
this.updatePluginTabs();
}, 100);
}
}
} else {
console.warn('[STUB] loadInstalledPluginsDirectly: Failed to load plugins:', data.message);
}
} catch (error) {
console.error('[STUB] loadInstalledPluginsDirectly: Error loading plugins:', error);
}
},
// Stub methods that will be replaced by full implementation
loadTabContent: function(tab) {},
loadInstalledPlugins: async function() {
// Try to use global function if available, otherwise use direct loading
if (typeof window.loadInstalledPlugins === 'function') {
await window.loadInstalledPlugins();
// Update tabs after loading (window.installedPlugins should be set by the global function)
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
this.installedPlugins = window.installedPlugins;
this.updatePluginTabs();
}
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
await window.pluginManager.loadInstalledPlugins();
// Update tabs after loading
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
this.installedPlugins = window.installedPlugins;
this.updatePluginTabs();
}
} else {
// Fallback to direct loading (which already calls updatePluginTabs)
await this.loadInstalledPluginsDirectly();
}
},
updatePluginTabs: function() {
// Basic implementation for stub - will be replaced by full implementation
// Debounce to prevent multiple rapid calls
if (this._updatePluginTabsTimeout) {
clearTimeout(this._updatePluginTabsTimeout);
}
this._updatePluginTabsTimeout = setTimeout(() => {
debugLog('[STUB] updatePluginTabs: Executing with', this.installedPlugins?.length || 0, 'plugins');
const pluginTabsRow = document.getElementById('plugin-tabs-row');
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
if (!pluginTabsRow || !pluginTabsNav) {
console.warn('[STUB] updatePluginTabs: Plugin tabs container not found');
return;
}
if (!this.installedPlugins || this.installedPlugins.length === 0) {
debugLog('[STUB] updatePluginTabs: No plugins to display');
return;
}
// Check if tabs are already correct by comparing plugin IDs
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
const existingIds = Array.from(existingTabs).map(tab => tab.getAttribute('data-plugin-id')).sort().join(',');
const currentIds = this.installedPlugins.map(p => p.id).sort().join(',');
if (existingIds === currentIds && existingTabs.length === this.installedPlugins.length) {
debugLog('[STUB] updatePluginTabs: Tabs already match, skipping update');
return;
}
// Clear existing plugin tabs (except Plugin Manager)
existingTabs.forEach(tab => { tab.remove(); });
debugLog('[STUB] updatePluginTabs: Cleared', existingTabs.length, 'existing tabs');
// Add tabs for each installed plugin
this.installedPlugins.forEach(plugin => {
const tabButton = document.createElement('button');
tabButton.type = 'button';
tabButton.setAttribute('data-plugin-id', plugin.id);
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
tabButton.onclick = () => {
this.activeTab = plugin.id;
if (typeof this.updatePluginTabStates === 'function') {
this.updatePluginTabStates();
}
};
// DOM APIs instead of innerHTML: manifest
// icon/name are semi-trusted input.
const tabIcon = document.createElement('i');
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
tabButton.textContent = '';
tabButton.appendChild(tabIcon);
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
pluginTabsNav.appendChild(tabButton);
});
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
}, 100);
},
showNotification: function(message, type) {},
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
};
};
})();
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
/* global debugLog */
// HTMX swap/script-execution configuration and section toggle helpers
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
// Configure HTMX to evaluate scripts in swapped content and fix insertBefore errors
(function() {
function setupScriptExecution() {
if (document.body) {
// Fix HTMX insertBefore errors by validating targets before swap
document.body.addEventListener('htmx:beforeSwap', function(event) {
try {
const target = event.detail.target;
if (!target) {
console.warn('[HTMX] Target is null, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Check if target is a valid DOM element
if (!(target instanceof Element)) {
console.warn('[HTMX] Target is not a valid Element, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Check if target has a parent node (required for insertBefore)
if (!target.parentNode) {
console.warn('[HTMX] Target has no parent node, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Ensure target is in the DOM
if (!document.body.contains(target) && !document.head.contains(target)) {
console.warn('[HTMX] Target is not in DOM, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Additional check: ensure parent is also in DOM
if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) {
console.warn('[HTMX] Target parent is not in DOM, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// All checks passed, allow swap
return true;
} catch (e) {
// If validation fails, cancel swap
console.warn('[HTMX] Error validating target:', e);
event.detail.shouldSwap = false;
return false;
}
});
// Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy
const originalError = console.error;
const originalWarn = console.warn;
console.error = function(...args) {
const errorStr = args.join(' ');
const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || '';
// Suppress HTMX insertBefore errors (comprehensive check)
// These occur when HTMX tries to swap content but the target element is null
// Usually happens due to timing/race conditions and is harmless
if (errorStr.includes("insertBefore") ||
errorStr.includes("Cannot read properties of null") ||
errorStr.includes("reading 'insertBefore'")) {
// Check if it's from HTMX by looking at stack trace or error string
// Also check the call stack if available
const isHtmxError = errorStr.includes('htmx') ||
errorStack.includes('htmx') ||
args.some(arg => {
if (typeof arg === 'string') {
return arg.includes('htmx');
}
// Check error objects for stack traces
if (arg && typeof arg === 'object' && arg.stack) {
return arg.stack.includes('htmx');
}
return false;
});
if (isHtmxError) {
return; // Suppress - this is a harmless HTMX timing/race condition issue
}
}
// Suppress script execution errors from malformed HTML
if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") ||
errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) {
if (errorStr.includes('Unexpected token')) {
return; // Suppress malformed HTML errors
}
}
originalError.apply(console, args);
};
console.warn = function(...args) {
const warnStr = args.join(' ');
// Suppress Permissions-Policy warnings (harmless browser warnings)
if (warnStr.includes('Permissions-Policy header') ||
warnStr.includes('Unrecognized feature') ||
warnStr.includes('Origin trial controlled feature') ||
warnStr.includes('browsing-topics') ||
warnStr.includes('run-ad-auction') ||
warnStr.includes('join-ad-interest-group') ||
warnStr.includes('private-state-token') ||
warnStr.includes('private-aggregation') ||
warnStr.includes('attribution-reporting')) {
return; // Suppress - these are harmless browser feature warnings
}
originalWarn.apply(console, args);
};
// Handle HTMX errors gracefully with detailed logging
document.body.addEventListener('htmx:responseError', function(event) {
const detail = event.detail;
const xhr = detail.xhr;
const target = detail.target;
// Enhanced error logging
console.error('HTMX response error:', {
status: xhr?.status,
statusText: xhr?.statusText,
url: xhr?.responseURL,
target: target?.id || target?.tagName,
responseText: xhr?.responseText
});
// For form submissions, log field names only — values
// may contain API keys, passwords, or other secrets
// that must never reach the console.
if (target && target.tagName === 'FORM') {
const formData = new FormData(target);
const fieldNames = [];
for (const [key] of formData.entries()) {
fieldNames.push(key);
}
console.error('Form fields (values redacted):', fieldNames);
// Try to parse error response for validation details
if (xhr?.responseText) {
try {
const errorData = JSON.parse(xhr.responseText);
console.error('Error details:', {
message: errorData.message,
details: errorData.details,
validation_errors: errorData.validation_errors,
context: errorData.context
});
} catch {
console.error('Error response (non-JSON):', xhr.responseText.substring(0, 500));
}
}
}
});
document.body.addEventListener('htmx:swapError', function(event) {
// Log but don't break the app
console.warn('HTMX swap error:', event.detail);
});
// Execute <script> tags in swapped content ourselves, on
// htmx:afterSwap (synchronous, right after the swap) rather
// than relying on htmx's own script handling, which runs
// during its later "settle" phase (~20ms after swap, per
// htmx's defaultSettleDelay). Alpine's MutationObserver
// processes newly-inserted x-data elements synchronously
// as soon as the swap lands, which is BEFORE htmx's settle
// phase - so any partial whose x-data component function
// (e.g. wifiSetup()) is defined by an inline <script> in
// that same partial would have that script still un-run
// when Alpine evaluates x-data, permanently failing with
// "wifiSetup is not defined" (Alpine does not retry).
// Disable htmx's own native script re-execution so the
// same script doesn't also run a second time via settle.
if (typeof htmx !== 'undefined' && htmx.config) {
htmx.config.allowScriptTags = false;
}
document.body.addEventListener('htmx:afterSwap', function(event) {
const target = event.detail && event.detail.target;
if (!target || !(target instanceof Element)) return;
target.querySelectorAll('script').forEach(function(oldScript) {
const newScript = document.createElement('script');
for (const attr of oldScript.attributes) {
newScript.setAttribute(attr.name, attr.value);
}
newScript.textContent = oldScript.textContent;
oldScript.replaceWith(newScript);
});
});
// Mark tab containers as loaded once their content settles, so switching
// away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab
// containers only) so modals and plugin config panels can still reload.
document.body.addEventListener('htmx:afterSettle', function(event) {
if (event.detail && event.detail.target) {
const target = event.detail.target;
const trigger = target.getAttribute('hx-trigger') || '';
if (trigger.includes('loadtab')) {
target.setAttribute('data-loaded', 'true');
}
}
});
} else {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupScriptExecution);
} else {
setTimeout(setupScriptExecution, 100);
}
}
}
setupScriptExecution();
// Section toggle function - define early so it's available for HTMX-loaded content
window.toggleSection = function(sectionId) {
const section = document.getElementById(sectionId);
const icon = document.getElementById(sectionId + '-icon');
if (!section) {
console.warn('toggleSection: Could not find section for', sectionId);
return;
}
if (!icon) {
console.warn('toggleSection: Could not find icon for', sectionId);
return;
}
// Check if currently hidden by checking both class and computed display
const hasHiddenClass = section.classList.contains('hidden');
const computedDisplay = window.getComputedStyle(section).display;
const isHidden = hasHiddenClass || computedDisplay === 'none';
if (isHidden) {
// Show the section - remove hidden class and explicitly set display to block
section.classList.remove('hidden');
section.style.display = 'block';
icon.classList.remove('fa-chevron-right');
icon.classList.add('fa-chevron-down');
} else {
// Hide the section - add hidden class and set display to none
section.classList.add('hidden');
section.style.display = 'none';
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-right');
}
// Keep assistive tech in sync: any toggle button that declares
// aria-controls for this section mirrors the expanded state.
const controlBtn = document.querySelector(`[aria-controls="${sectionId}"]`);
if (controlBtn) {
controlBtn.setAttribute('aria-expanded', String(isHidden));
}
};
})();
@@ -173,7 +173,14 @@
function setActiveTab(tab) {
var data = getAppData();
if (data) { data.activeTab = tab; return true; }
if (data) {
data.activeTab = tab;
// Navigating from a search result should also dismiss the mobile
// nav drawer (harmless no-op on desktop, where the drawer CSS
// doesn't apply).
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
return true;
}
return false;
}
@@ -160,6 +160,24 @@
// ─── Cell rendering ─────────────────────────────────────────────────────
/**
* Visible text for one enum option.
*
* Mirrors the server-rendered table in plugin_config.html: a schema may
* supply x-options.labels, and anything unlabelled falls back to the raw
* value. Rows added here must match rows rendered by the template, or the
* same column would read differently before and after a page reload.
*
* @param {Object} colDef column (or property) schema
* @param {*} opt the enum value
* @returns {string} label to display
*/
function enumOptionLabel(colDef, opt) {
const options = (colDef && (colDef['x-options'] || colDef['x_options'])) || {};
const labels = options.labels || {};
return Object.prototype.hasOwnProperty.call(labels, opt) ? labels[opt] : opt;
}
/**
* Create one <td> for a display column.
*/
@@ -219,7 +237,7 @@
if (opt === null) return;
const o = document.createElement('option');
o.value = opt;
o.textContent = opt;
o.textContent = enumOptionLabel(colDef, opt);
if (String(colValue) === String(opt)) o.selected = true;
sel.appendChild(o);
});
@@ -646,7 +664,7 @@
enumVals.forEach(opt => {
if (opt === null) return;
const o = document.createElement('option');
o.value = opt; o.textContent = opt;
o.value = opt; o.textContent = enumOptionLabel(schema, opt);
if (String(currentVal) === String(opt)) o.selected = true;
sel.appendChild(o);
});
@@ -209,6 +209,7 @@
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.setAttribute('aria-label', 'Remove feed');
removeButton.addEventListener('click', function() {
window.removeCustomFeedRow(this);
});
@@ -333,6 +334,7 @@
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.setAttribute('aria-label', 'Remove feed');
removeButton.addEventListener('click', function() {
window.removeCustomFeedRow(this);
});
@@ -404,9 +406,18 @@
if (!file) return;
const formData = new FormData();
formData.append('file', file);
// Backend contract (see api_v3.upload_plugin_asset): the request
// field must be named "files" (it does request.files.getlist('files')
// and 400s with "No files provided" otherwise), and the response
// carries the result in a top-level "uploaded_files" key, not nested
// under "data". file-upload-single.js's working upload flow uses this
// same contract.
// Backend contract (api_v3.upload_plugin_asset): field must be named
// "files" (request.files.getlist('files')), and the response carries
// results in a top-level "uploaded_files" key, not nested under "data".
formData.append('files', file);
formData.append('plugin_id', pluginId);
fetch('/api/v3/plugins/assets/upload', {
method: 'POST',
body: formData
@@ -421,8 +432,8 @@
return response.json();
})
.then(data => {
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
const uploadedFile = data.data.files[0];
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
const uploadedFile = data.uploaded_files[0];
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
if (row) {
const logoCell = row.querySelector('td:nth-child(3)');
@@ -495,8 +506,6 @@
// Append container to logoCell
logoCell.appendChild(container);
}
// Allow re-uploading the same file
event.target.value = '';
} else {
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
@@ -506,6 +515,12 @@
console.error('Upload error:', error);
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + error.message, 'error');
})
.finally(() => {
// Reset regardless of outcome, so the same file can be re-selected
// to retry after a failure (browsers won't fire "change" again
// for an input that still holds that exact file).
event.target.value = '';
});
};
@@ -8,6 +8,10 @@
* 1. Copy this file to your plugin's widgets directory
* 2. Reference it in your config_schema.json with "x-widget": "color-picker"
* 3. The widget will be automatically loaded when the plugin config form is rendered
*
* Do NOT add this file to base.html's widget script list: it registers
* under the name 'color-picker' and would shadow the built-in
* color-picker.js widget.
*
* @module ColorPickerWidget
*/
@@ -0,0 +1,196 @@
/**
* Google OAuth Widget
*
* Step 2 of the calendar plugin's setup, between uploading the OAuth client
* file and picking calendars. Google will not let a headless device complete
* consent on its own, so the flow is necessarily two calls with a human in
* between:
*
* 1. POST /api/v3/plugins/calendar/authenticate with no body
* -> { auth_url } to open in a browser
* 2. the browser lands on a loopback address that fails to load; its URL
* carries the authorization code. POST it back as redirect_url
* -> the server exchanges it and writes token.pickle
*
* The failed page in step 2 is expected and is worth saying out loud, because
* it looks exactly like something went wrong.
*
* @module GoogleOAuthWidget
*/
(function () {
'use strict';
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[GoogleOAuthWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
return;
}
const ENDPOINT = '/api/v3/plugins/calendar/authenticate';
window.LEDMatrixWidgets.register('google-oauth', {
name: 'Google OAuth Widget',
version: '1.0.0',
/**
* @param {HTMLElement} container
* @param {Object} config - schema config (unused)
* @param {*} value - unused; this widget stores nothing
* @param {Object} options - { fieldId, pluginId, name }
*/
render: function (container, config, value, options) {
const fieldId = options.fieldId;
// Nothing is stored in config by this step -- the result is
// token.pickle on the device -- but the form still expects a field.
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = fieldId + '_hidden';
hidden.name = options.name;
hidden.value = value || '';
const startBtn = document.createElement('button');
startBtn.type = 'button';
startBtn.className = 'px-3 py-1.5 text-sm rounded-md bg-blue-600 hover:bg-blue-700 text-white';
startBtn.innerHTML = '<i class="fas fa-key"></i> Connect Google Account';
const status = document.createElement('p');
status.className = 'text-xs text-gray-400 mt-2';
// Every message this widget gives -- the consent link is ready,
// the exchange failed -- arrives here after an async call, so a
// screen reader is told nothing unless it is a live region.
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
const step2 = document.createElement('div');
step2.className = 'mt-3 hidden';
const link = document.createElement('a');
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'text-blue-400 underline text-sm break-all';
link.textContent = 'Open the Google consent screen';
// Deliberately loud. After consent the browser is redirected to a
// loopback address nothing is listening on, so it lands on a
// browser error page -- which reads as a failure at exactly the
// moment the user has to act on it. Said quietly in grey it gets
// missed, and the flow looks broken when it is working.
const hint = document.createElement('div');
hint.className =
'mt-3 p-3 rounded-md border border-amber-500/60 bg-amber-500/10';
hint.innerHTML =
'<p class="text-sm text-amber-300 font-semibold">'
+ '<i class="fas fa-triangle-exclamation"></i> '
+ 'The next page will fail to load. That is expected.</p>'
+ '<p class="text-xs text-amber-200/90 mt-1">'
+ 'After you approve access, Google sends your browser to '
+ '<code>127.0.0.1</code>, where nothing is running \u2014 so you will see '
+ '"This site can\u2019t be reached" or similar. Nothing has gone wrong. '
+ 'Copy the <strong>entire address</strong> out of the address bar '
+ '(it contains <code>?code=...</code>) and paste it in the box below.</p>';
const codeInputId = fieldId + '_redirect_url';
const codeLabel = document.createElement('label');
codeLabel.className = 'block text-xs text-gray-300 mt-3';
codeLabel.textContent = 'Paste the address from that failed page here:';
// The label was visible but not associated, so the input still had
// no accessible name -- a placeholder is not one, and it vanishes
// on focus, which is exactly when the value is being pasted.
codeLabel.setAttribute('for', codeInputId);
const codeInput = document.createElement('input');
codeInput.type = 'text';
codeInput.id = codeInputId;
codeInput.placeholder = 'http://127.0.0.1/?code=...';
codeInput.className =
'mt-2 block w-full px-3 py-2 text-sm border border-gray-600 '
+ 'rounded-md bg-gray-800 text-gray-100';
const finishBtn = document.createElement('button');
finishBtn.type = 'button';
finishBtn.className = 'mt-2 px-3 py-1.5 text-sm rounded-md bg-green-600 hover:bg-green-700 text-white';
finishBtn.innerHTML = '<i class="fas fa-check"></i> Finish Authentication';
function say(message, kind) {
status.textContent = message;
status.className = 'text-xs mt-2 ' + (
kind === 'error' ? 'text-red-400'
: kind === 'success' ? 'text-green-400'
: 'text-gray-400');
}
function post(body) {
return fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
}).then(function (r) {
return r.json().catch(function () {
// A non-JSON body here means the request never reached
// the handler -- worth saying so rather than "undefined".
return { status: 'error', message: 'Server returned ' + r.status };
});
});
}
startBtn.addEventListener('click', function () {
startBtn.disabled = true;
say('Requesting a consent link...');
post({}).then(function (data) {
startBtn.disabled = false;
if (data.status !== 'success' || !data.auth_url) {
say(data.message || 'Could not start authentication.', 'error');
return;
}
link.href = data.auth_url;
step2.classList.remove('hidden');
say(data.message || 'Open the link, approve, then paste the address back.');
}).catch(function (err) {
startBtn.disabled = false;
say('Request failed: ' + err.message, 'error');
});
});
finishBtn.addEventListener('click', function () {
const pasted = codeInput.value.trim();
if (!pasted) {
say('Paste the address your browser was redirected to.', 'error');
return;
}
finishBtn.disabled = true;
say('Exchanging the code with Google...');
post({ redirect_url: pasted }).then(function (data) {
finishBtn.disabled = false;
if (data.status !== 'success') {
say(data.message || 'Authentication failed.', 'error');
return;
}
say(data.message || 'Authenticated.', 'success');
step2.classList.add('hidden');
codeInput.value = '';
}).catch(function (err) {
finishBtn.disabled = false;
say('Request failed: ' + err.message, 'error');
});
});
step2.appendChild(link);
step2.appendChild(hint);
step2.appendChild(codeLabel);
step2.appendChild(codeInput);
step2.appendChild(finishBtn);
container.appendChild(hidden);
container.appendChild(startBtn);
container.appendChild(status);
container.appendChild(step2);
},
getValue: function (fieldId) {
const hidden = document.getElementById(fieldId + '_hidden');
return hidden ? hidden.value : '';
}
});
})();
@@ -58,6 +58,10 @@
// Track active notifications
let activeNotifications = [];
// onAction callbacks for notifications with an inline action button,
// keyed by notification id (cleaned up on dismiss). A Map rather than a
// plain object so ids can never collide with prototype properties.
const actionCallbacks = new Map();
let notificationCounter = 0;
/**
@@ -113,6 +117,7 @@
// Remove from tracking array
activeNotifications = activeNotifications.filter(id => id !== notificationId);
actionCallbacks.delete(notificationId);
}
/**
@@ -158,6 +163,20 @@
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
// The callback is stored by id and invoked via triggerAction, which
// also dismisses the notification.
if (options.actionLabel && typeof options.onAction === 'function') {
actionCallbacks.set(notificationId, options.onAction);
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
${escapeHtml(options.actionLabel)}
</button>
`;
}
if (dismissible) {
html += `
<button type="button"
@@ -227,6 +246,17 @@
removeNotification(notificationId);
},
/**
* Invoke a notification's onAction callback (see options.actionLabel /
* options.onAction on show) and dismiss it.
* @param {string} notificationId - Notification ID whose action to run
*/
triggerAction: function(notificationId) {
const cb = actionCallbacks.get(notificationId);
removeNotification(notificationId);
if (typeof cb === 'function') cb();
},
/**
* Clear all notifications
*/
@@ -262,9 +292,11 @@
}
});
// Global shorthand function (backwards compatible with existing code)
// Global shorthand function (backwards compatible with existing code).
// Accepts either the legacy type string or a full options object
// ({ type, duration, actionLabel, onAction, ... }).
window.showNotification = function(message, type = 'info') {
return showNotification(message, { type: type });
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
};
// Initialize container on load
@@ -0,0 +1,225 @@
/**
* Plugin Order List shared drag-and-drop reorder list of enabled plugins.
*
* Factored out of the Vegas Scroll section of display.html so both Vegas mode
* and the primary rotation (Durations tab) use one implementation. Renders
* one draggable row per enabled plugin into a container and keeps a hidden
* input's value in sync as a JSON array of plugin ids in display order.
*
* Usage:
* PluginOrderList.init({
* containerId: 'vegas_plugin_order', // rows render here
* orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids
* excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an
* // include-checkbox per row; unchecked ids collect here (JSON array)
* showVegasModeBadge: true // optional: Scroll/Fixed/Static badge
* });
*
* The container re-renders from /api/v3/plugins/installed each init; the
* hidden input(s) must already hold the saved order/exclusions (JSON).
*/
(function() {
'use strict';
const MODE_LABELS = new Map([
['scroll', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }],
['fixed', { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }],
['static', { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }]
]);
function init(options) {
const container = document.getElementById(options.containerId);
const orderInput = document.getElementById(options.orderInputId);
const excludedInput = options.excludedInputId ? document.getElementById(options.excludedInputId) : null;
if (!container || !orderInput) return;
function syncInputs() {
const order = [];
const excluded = [];
container.querySelectorAll('.plugin-order-item').forEach(item => {
const pluginId = item.dataset.pluginId;
order.push(pluginId);
const checkbox = item.querySelector('.plugin-order-include');
if (checkbox && !checkbox.checked) excluded.push(pluginId);
});
orderInput.value = JSON.stringify(order);
if (excludedInput) excludedInput.value = JSON.stringify(excluded);
}
function setupDragAndDrop() {
let draggedItem = null;
container.querySelectorAll('.plugin-order-item').forEach(item => {
item.addEventListener('dragstart', function(e) {
draggedItem = this;
this.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', function() {
this.style.opacity = '1';
draggedItem = null;
syncInputs();
});
item.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = this.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
this.style.borderTop = '2px solid #3b82f6';
this.style.borderBottom = '';
} else {
this.style.borderBottom = '2px solid #3b82f6';
this.style.borderTop = '';
}
});
item.addEventListener('dragleave', function() {
this.style.borderTop = '';
this.style.borderBottom = '';
});
item.addEventListener('drop', function(e) {
e.preventDefault();
this.style.borderTop = '';
this.style.borderBottom = '';
if (draggedItem && draggedItem !== this) {
const rect = this.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
container.insertBefore(draggedItem, this);
} else {
container.insertBefore(draggedItem, this.nextSibling);
}
}
});
});
}
fetch('/api/v3/plugins/installed')
.then(response => response.json())
.then(data => {
const allPlugins = (data.data && data.data.plugins) || data.plugins || [];
const plugins = allPlugins.filter(p => p.enabled);
if (plugins.length === 0) {
const empty = document.createElement('p');
empty.className = 'text-sm text-gray-500 italic';
empty.textContent = 'No enabled plugins';
container.textContent = '';
container.appendChild(empty);
return;
}
let currentOrder = [];
let excluded = [];
try {
currentOrder = JSON.parse(orderInput.value || '[]');
if (excludedInput) excluded = JSON.parse(excludedInput.value || '[]');
} catch (e) {
console.error('Error parsing saved plugin order:', e);
}
// JSON.parse can succeed and still return null/objects
// (e.g. a saved value of "null"); normalize to arrays.
if (!Array.isArray(currentOrder)) currentOrder = [];
if (!Array.isArray(excluded)) excluded = [];
// Saved order first, then any newly enabled plugins.
const orderedPlugins = [];
currentOrder.forEach(id => {
const plugin = plugins.find(p => p.id === id);
if (plugin) orderedPlugins.push(plugin);
});
plugins.forEach(plugin => {
if (!orderedPlugins.find(p => p.id === plugin.id)) orderedPlugins.push(plugin);
});
// Rows are built with DOM APIs rather than innerHTML — plugin
// ids/names come from installed manifests (semi-trusted).
container.textContent = '';
orderedPlugins.forEach(plugin => {
const row = document.createElement('div');
row.className = 'flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move plugin-order-item';
row.dataset.pluginId = plugin.id;
row.draggable = true;
const grip = document.createElement('i');
grip.className = 'fas fa-grip-vertical text-gray-400 mr-3';
row.appendChild(grip);
if (excludedInput) {
const isExcluded = excluded.includes(plugin.id);
const label = document.createElement('label');
label.className = 'flex items-center flex-1';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'plugin-order-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2';
checkbox.checked = !isExcluded;
const name = document.createElement('span');
name.className = 'text-sm font-medium text-gray-700';
name.textContent = plugin.name || plugin.id;
label.appendChild(checkbox);
label.appendChild(name);
row.appendChild(label);
} else {
const name = document.createElement('span');
name.className = 'text-sm font-medium text-gray-700 flex-1';
name.textContent = plugin.name || plugin.id;
row.appendChild(name);
}
if (options.showVegasModeBadge) {
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
const modeInfo = MODE_LABELS.get(vegasMode) || MODE_LABELS.get('fixed');
const badge = document.createElement('span');
badge.className = `text-xs ${modeInfo.color} ml-2`;
badge.title = `Vegas display mode: ${modeInfo.label}`;
const badgeIcon = document.createElement('i');
badgeIcon.className = `fas ${modeInfo.icon} mr-1`;
badge.appendChild(badgeIcon);
badge.appendChild(document.createTextNode(modeInfo.label));
row.appendChild(badge);
}
// Up/down buttons: touch- and keyboard-accessible
// reordering alongside native drag-and-drop (HTML5 drag
// events don't fire on most mobile browsers).
const pluginLabel = plugin.name || plugin.id;
[['up', 'fa-chevron-up', `Move ${pluginLabel} up`],
['down', 'fa-chevron-down', `Move ${pluginLabel} down`]].forEach(([dir, iconCls, ariaLabel]) => {
const moveBtn = document.createElement('button');
moveBtn.type = 'button';
moveBtn.className = 'plugin-order-move text-gray-400 hover:text-gray-700 px-2 py-1';
moveBtn.setAttribute('aria-label', ariaLabel);
const moveIcon = document.createElement('i');
moveIcon.className = `fas ${iconCls} text-xs`;
moveBtn.appendChild(moveIcon);
moveBtn.addEventListener('click', function() {
if (dir === 'up' && row.previousElementSibling) {
container.insertBefore(row, row.previousElementSibling);
} else if (dir === 'down' && row.nextElementSibling) {
container.insertBefore(row.nextElementSibling, row);
}
syncInputs();
moveBtn.focus();
});
row.appendChild(moveBtn);
});
container.appendChild(row);
});
setupDragAndDrop();
container.querySelectorAll('.plugin-order-include').forEach(checkbox => {
checkbox.addEventListener('change', syncInputs);
});
syncInputs();
})
.catch(error => {
console.error('Error fetching plugins:', error);
const err = document.createElement('p');
err.className = 'text-sm text-red-500';
err.textContent = 'Error loading plugins';
container.textContent = '';
container.appendChild(err);
});
}
window.PluginOrderList = { init: init };
})();
@@ -57,6 +57,11 @@
target.appendChild(frag);
}
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[TimePicker] LEDMatrixWidgets registry not loaded');
return;
}
window.LEDMatrixWidgets.register('time-picker', {
name: 'Time Picker Widget',
version: '1.0.0',
+24
View File
@@ -0,0 +1,24 @@
{
"name": "LED Matrix Control",
"short_name": "LEDMatrix",
"description": "Control panel for the LEDMatrix display",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#111827",
"theme_color": "#111827",
"icons": [
{
"src": "/static/v3/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/v3/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(P){var t={pairs:"()[]{}''\"\"",closeBefore:")]}'\":;>",triples:"",explode:"[]{}"},S=P.Pos;function k(e,n){return"pairs"==n&&"string"==typeof e?e:("object"==typeof e&&null!=e[n]?e:t)[n]}P.defineOption("autoCloseBrackets",!1,function(e,n,t){t&&t!=P.Init&&(e.removeKeyMap(i),e.state.closeBrackets=null),n&&(r(k(n,"pairs")),e.state.closeBrackets=n,e.addKeyMap(i))});var i={Backspace:function(e){var n=y(e);if(!n||e.getOption("disableInput"))return P.Pass;for(var t=k(n,"pairs"),r=e.listSelections(),i=0;i<r.length;i++){if(!r[i].empty())return P.Pass;var a=s(e,r[i].head);if(!a||t.indexOf(a)%2!=0)return P.Pass}for(i=r.length-1;0<=i;i--){var o=r[i].head;e.replaceRange("",S(o.line,o.ch-1),S(o.line,o.ch+1),"+delete")}},Enter:function(r){var e=y(r),n=e&&k(e,"explode");if(!n||r.getOption("disableInput"))return P.Pass;for(var i=r.listSelections(),t=0;t<i.length;t++){if(!i[t].empty())return P.Pass;var a=s(r,i[t].head);if(!a||n.indexOf(a)%2!=0)return P.Pass}r.operation(function(){var e=r.lineSeparator()||"\n";r.replaceSelection(e+e,null),O(r,-1),i=r.listSelections();for(var n=0;n<i.length;n++){var t=i[n].head.line;r.indentLine(t,null,!0),r.indentLine(t+1,null,!0)}})}};function r(e){for(var n=0;n<e.length;n++){var t=e.charAt(n),r="'"+t+"'";i[r]||(i[r]=function(n){return function(e){return function(i,e){var n=y(i);if(!n||i.getOption("disableInput"))return P.Pass;var t=k(n,"pairs"),r=t.indexOf(e);if(-1==r)return P.Pass;for(var a,o=k(n,"closeBefore"),s=k(n,"triples"),l=t.charAt(r+1)==e,c=i.listSelections(),f=r%2==0,h=0;h<c.length;h++){var u,d=c[h],p=d.head,g=i.getRange(p,S(p.line,p.ch+1));if(f&&!d.empty())u="surround";else if(!l&&f||g!=e)if(l&&1<p.ch&&0<=s.indexOf(e)&&i.getRange(S(p.line,p.ch-2),p)==e+e){if(2<p.ch&&/\bstring/.test(i.getTokenTypeAt(S(p.line,p.ch-2))))return P.Pass;u="addFour"}else if(l){d=0==p.ch?" ":i.getRange(S(p.line,p.ch-1),p);if(P.isWordChar(g)||d==e||P.isWordChar(d))return P.Pass;u="both"}else{if(!f||!(0===g.length||/\s/.test(g)||-1<o.indexOf(g)))return P.Pass;u="both"}else u=l&&function(e,n){var t=e.getTokenAt(S(n.line,n.ch+1));return/\bstring/.test(t.type)&&t.start==n.ch&&(0==n.ch||!/\bstring/.test(e.getTokenTypeAt(n)))}(i,p)?"both":0<=s.indexOf(e)&&i.getRange(p,S(p.line,p.ch+3))==e+e+e?"skipThree":"skip";if(a){if(a!=u)return P.Pass}else a=u}var v=r%2?t.charAt(r-1):e,b=r%2?e:t.charAt(r+1);i.operation(function(){if("skip"==a)O(i,1);else if("skipThree"==a)O(i,3);else if("surround"==a){for(var e=i.getSelections(),n=0;n<e.length;n++)e[n]=v+e[n]+b;i.replaceSelections(e,"around");for(e=i.listSelections().slice(),n=0;n<e.length;n++)e[n]=(t=e[n],r=void 0,r=0<P.cmpPos(t.anchor,t.head),{anchor:new S(t.anchor.line,t.anchor.ch+(r?-1:1)),head:new S(t.head.line,t.head.ch+(r?1:-1))});i.setSelections(e)}else"both"==a?(i.replaceSelection(v+b,null),i.triggerElectric(v+b),O(i,-1)):"addFour"==a&&(i.replaceSelection(v+v+v+v,"before"),O(i,1));var t,r})}(e,n)}}(t))}}function y(e){var n=e.state.closeBrackets;return n&&!n.override&&e.getModeAt(e.getCursor()).closeBrackets||n}function O(e,n){for(var t=[],r=e.listSelections(),i=0,a=0;a<r.length;a++){var o=r[a];o.head==e.getCursor()&&(i=a);o=o.head.ch||0<n?{line:o.head.line,ch:o.head.ch+n}:{line:o.head.line-1};t.push({anchor:o,head:o})}e.setSelections(t,i)}function s(e,n){n=e.getRange(S(n.line,n.ch-1),S(n.line,n.ch+1));return 2==n.length?n:null}r(t.pairs+"`")});
@@ -0,0 +1 @@
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){var u=/MSIE \d/.test(navigator.userAgent)&&(null==document.documentMode||document.documentMode<8),k=r.Pos,p={"(":")>",")":"(<","[":"]>","]":"[<","{":"}>","}":"{<","<":">>",">":"<<"};function y(t){return t&&t.bracketRegex||/[(){}[\]]/}function f(t,e,n){var r=t.getLineHandle(e.line),i=e.ch-1,c=n&&n.afterCursor;null==c&&(c=/(^| )cm-fat-cursor($| )/.test(t.getWrapperElement().className));var a=y(n),c=!c&&0<=i&&a.test(r.text.charAt(i))&&p[r.text.charAt(i)]||a.test(r.text.charAt(i+1))&&p[r.text.charAt(++i)];if(!c)return null;a=">"==c.charAt(1)?1:-1;if(n&&n.strict&&0<a!=(i==e.ch))return null;r=t.getTokenTypeAt(k(e.line,i+1)),n=o(t,k(e.line,i+(0<a?1:0)),a,r,n);return null==n?null:{from:k(e.line,i),to:n&&n.pos,match:n&&n.ch==c.charAt(0),forward:0<a}}function o(t,e,n,r,i){for(var c=i&&i.maxScanLineLength||1e4,a=i&&i.maxScanLines||1e3,o=[],h=y(i),l=0<n?Math.min(e.line+a,t.lastLine()+1):Math.max(t.firstLine()-1,e.line-a),s=e.line;s!=l;s+=n){var u=t.getLine(s);if(u){var f=0<n?0:u.length-1,m=0<n?u.length:-1;if(!(u.length>c))for(s==e.line&&(f=e.ch-(n<0?1:0));f!=m;f+=n){var g=u.charAt(f);if(h.test(g)&&(void 0===r||(t.getTokenTypeAt(k(s,f+1))||"")==(r||""))){var d=p[g];if(d&&">"==d.charAt(1)==0<n)o.push(g);else{if(!o.length)return{pos:k(s,f),ch:g};o.pop()}}}}}return s-n!=(0<n?t.lastLine():t.firstLine())&&null}function e(t,e,n){for(var r=t.state.matchBrackets.maxHighlightLineLength||1e3,i=n&&n.highlightNonMatching,c=[],a=t.listSelections(),o=0;o<a.length;o++){var h,l=a[o].empty()&&f(t,a[o].head,n);l&&(l.match||!1!==i)&&t.getLine(l.from.line).length<=r&&(h=l.match?"CodeMirror-matchingbracket":"CodeMirror-nonmatchingbracket",c.push(t.markText(l.from,k(l.from.line,l.from.ch+1),{className:h})),l.to&&t.getLine(l.to.line).length<=r&&c.push(t.markText(l.to,k(l.to.line,l.to.ch+1),{className:h})))}if(c.length){u&&t.state.focused&&t.focus();function s(){t.operation(function(){for(var t=0;t<c.length;t++)c[t].clear()})}if(!e)return s;setTimeout(s,800)}}function i(t){t.operation(function(){t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null),t.state.matchBrackets.currentlyHighlighted=e(t,!1,t.state.matchBrackets)})}function c(t){t.state.matchBrackets&&t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null)}r.defineOption("matchBrackets",!1,function(t,e,n){n&&n!=r.Init&&(t.off("cursorActivity",i),t.off("focus",i),t.off("blur",c),c(t)),e&&(t.state.matchBrackets="object"==typeof e?e:{},t.on("cursorActivity",i),t.on("focus",i),t.on("blur",c))}),r.defineExtension("matchBrackets",function(){e(this,!0)}),r.defineExtension("findMatchingBracket",function(t,e,n){return f(this,t,e=n||"boolean"==typeof e?n?(n.strict=e,n):e?{strict:!0}:null:e)}),r.defineExtension("scanForBracket",function(t,e,n,r){return o(this,t,e,n,r)})});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.cm-s-monokai.CodeMirror{background:#272822;color:#f8f8f2}.cm-s-monokai div.CodeMirror-selected{background:#49483e}.cm-s-monokai .CodeMirror-line::selection,.cm-s-monokai .CodeMirror-line>span::selection,.cm-s-monokai .CodeMirror-line>span>span::selection{background:rgba(73,72,62,.99)}.cm-s-monokai .CodeMirror-line::-moz-selection,.cm-s-monokai .CodeMirror-line>span::-moz-selection,.cm-s-monokai .CodeMirror-line>span>span::-moz-selection{background:rgba(73,72,62,.99)}.cm-s-monokai .CodeMirror-gutters{background:#272822;border-right:0}.cm-s-monokai .CodeMirror-guttermarker{color:#fff}.cm-s-monokai .CodeMirror-guttermarker-subtle{color:#d0d0d0}.cm-s-monokai .CodeMirror-linenumber{color:#d0d0d0}.cm-s-monokai .CodeMirror-cursor{border-left:1px solid #f8f8f0}.cm-s-monokai span.cm-comment{color:#75715e}.cm-s-monokai span.cm-atom{color:#ae81ff}.cm-s-monokai span.cm-number{color:#ae81ff}.cm-s-monokai span.cm-comment.cm-attribute{color:#97b757}.cm-s-monokai span.cm-comment.cm-def{color:#bc9262}.cm-s-monokai span.cm-comment.cm-tag{color:#bc6283}.cm-s-monokai span.cm-comment.cm-type{color:#5998a6}.cm-s-monokai span.cm-attribute,.cm-s-monokai span.cm-property{color:#a6e22e}.cm-s-monokai span.cm-keyword{color:#f92672}.cm-s-monokai span.cm-builtin{color:#66d9ef}.cm-s-monokai span.cm-string{color:#e6db74}.cm-s-monokai span.cm-variable{color:#f8f8f2}.cm-s-monokai span.cm-variable-2{color:#9effff}.cm-s-monokai span.cm-type,.cm-s-monokai span.cm-variable-3{color:#66d9ef}.cm-s-monokai span.cm-def{color:#fd971f}.cm-s-monokai span.cm-bracket{color:#f8f8f2}.cm-s-monokai span.cm-tag{color:#f92672}.cm-s-monokai span.cm-header{color:#ae81ff}.cm-s-monokai span.cm-link{color:#ae81ff}.cm-s-monokai span.cm-error{background:#f92672;color:#f8f8f0}.cm-s-monokai .CodeMirror-activeline-background{background:#373831}.cm-s-monokai .CodeMirror-matchingbracket{text-decoration:underline;color:#fff!important}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -87,7 +87,7 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
</div>
<div class="footer">
<a href="/v3">Open Full Interface</a>
<a href="/">Open Full Interface</a>
</div>
<script>
+398 -219
View File
@@ -22,7 +22,7 @@
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
(<code class="bg-yellow-100 px-1 rounded">first_time_install.sh</code>)
and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy).
Check the <a href="/v3/logs" class="underline font-medium">Logs tab</a> for the full error.
Check the <a href="#" @click.prevent="activeTab = 'logs'" class="underline font-medium">Logs tab</a> for the full error.
</p>
</div>
@@ -30,7 +30,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="showNotification(event.detail.xhr.responseJSON?.message || 'Display settings saved', event.detail.xhr.responseJSON?.status || 'success')"
hx-on:htmx:after-request="showDisplaySaveResult(event.detail.xhr)"
class="space-y-6"
novalidate
onsubmit="fixInvalidNumberInputs(this); return true;">
@@ -47,7 +47,7 @@
name="rows"
value="{{ main_config.display.hardware.rows or 32 }}"
min="1"
max="64"
max="128"
class="form-control">
</div>
@@ -85,7 +85,14 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Live total-resolution readout: width = cols x chain_length, height = rows x parallel -->
<p id="display-resolution-readout" class="text-sm text-gray-600 mb-4" aria-live="polite">
<i class="fas fa-expand-arrows-alt mr-1 text-gray-400"></i>
Your display: <strong id="display-resolution-value">&mdash;</strong>
<span class="text-gray-400">(columns &times; chain length wide, rows &times; parallel tall)</span>
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" id="setting-display-brightness" data-setting-key="display.hardware.brightness">
<label for="brightness" class="block text-sm font-medium text-gray-700">Brightness{{ ui.help_tip('Overall LED brightness (1100%).\nLower is dimmer, higher is brighter. Recommended: 7090 indoors, 90100 in bright rooms.', 'Brightness') }}</label>
<div class="flex items-center space-x-2">
@@ -109,9 +116,15 @@
<option value="regular-pi1" {% if main_config.display.hardware.hardware_mapping == "regular-pi1" %}selected{% endif %}>Regular Pi1</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" id="setting-display-orientation" data-setting-key="display.hardware.orientation">
<label for="orientation" class="block text-sm font-medium text-gray-700">Panel Orientation{{ ui.help_tip('Rotates the rendered image to match how the panel is physically mounted.\nUse "Upside Down" if you flipped the panel 180° to move the Raspberry Pi / wiring to a more convenient side.', 'Panel Orientation') }}</label>
<select id="orientation" name="orientation" class="form-control">
<option value="normal" {% if main_config.display.hardware.get('orientation', 'normal') == "normal" %}selected{% endif %}>Normal</option>
<option value="180" {% if main_config.display.hardware.get('orientation', 'normal') == "180" %}selected{% endif %}>Upside Down (180°)</option>
</select>
</div>
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
@@ -123,7 +136,29 @@
<option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option>
</select>
</div>
</div>
<!-- Advanced hardware settings: niche-panel and deep tuning fields.
Collapsed by default; reuses the same nested-section shell as
plugin config forms, so toggleSection() and the settings
search's auto-expand both work unchanged. -->
<div class="nested-section border border-gray-300 rounded-lg mt-4">
<button type="button"
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
aria-controls="display-section-advanced-hardware"
aria-expanded="false"
onclick="toggleSection('display-section-advanced-hardware')">
<div class="flex-1">
<h4 class="font-semibold text-gray-900">
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware &amp; Display Options (15)
</h4>
<p class="text-sm text-gray-600 mt-1">Multiplexing, panel variants, PWM tuning, and display options &mdash; the defaults work for standard HUB75 panels.</p>
</div>
<i id="display-section-advanced-hardware-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
</button>
<div id="display-section-advanced-hardware" class="nested-content bg-gray-50 px-4 py-4 space-y-4 hidden" style="display: none;">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" id="setting-display-multiplexing" data-setting-key="display.hardware.multiplexing">
<label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing{{ ui.help_tip('Pixel-mapping scheme used by outdoor/specialty panels.\nLeave at 0 (Direct) for most indoor panels. Only change if the image is scrambled — try values until it looks right.', 'Multiplexing') }}</label>
<select id="multiplexing" name="multiplexing" class="form-control">
@@ -255,47 +290,6 @@
class="form-control">
</div>
</div>
</div>
<!-- Double-Sided Display -->
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain &mdash; e.g. two 64&times;32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
<label class="flex items-center gap-2">
<input type="checkbox"
id="double_sided_enabled"
name="double_sided_enabled"
value="true"
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="text-sm font-medium text-gray-700">Enabled</span>
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
</label>
</div>
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (28).\nMust divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
<input type="number"
id="double_sided_copies"
name="double_sided_copies"
value="{{ main_config.display.get('double_sided', {}).get('copies', 2) }}"
min="2"
max="8"
class="form-control">
</div>
<div class="form-group" id="setting-display-double_sided_axis" data-setting-key="display.double_sided.axis">
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis{{ ui.help_tip('Direction the display is divided into copies.\nHorizontal splits along the chained panels (side by side); Vertical splits along parallel chains (stacked).', 'Split Axis') }}</label>
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal &mdash; chained panels (side by side)</option>
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical &mdash; parallel chains (stacked)</option>
</select>
</div>
</div>
</div>
<!-- Display Options -->
<div class="bg-gray-50 rounded-lg p-4">
@@ -368,6 +362,36 @@
</div>
</div>
</div>
</div> <!-- /#display-section-advanced-hardware (nested-content) -->
</div> <!-- /advanced hardware nested-section -->
<script>
// Live "Your display: W x H" readout - width = cols x chain_length,
// height = rows x parallel (same math as the chain-length tooltip).
(function () {
const ids = ['rows', 'cols', 'chain_length', 'parallel'];
const out = document.getElementById('display-resolution-value');
if (!out) return;
function recompute() {
const v = {};
for (const id of ids) {
const el = document.getElementById(id);
v[id] = el ? parseInt(el.value, 10) : NaN;
}
if (Object.values(v).some(n => !Number.isFinite(n) || n <= 0)) {
out.textContent = '—';
return;
}
out.textContent = (v.cols * v.chain_length) + ' × ' + (v.rows * v.parallel) + ' pixels';
}
for (const id of ids) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', recompute);
}
recompute();
})();
</script>
</div>
<!-- Vegas Scroll Mode Settings -->
<div class="bg-gray-50 rounded-lg p-4 mt-6">
@@ -409,7 +433,7 @@
</div>
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', 'Separator Width') }}</label>
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap where one plugin hands off to the next (0128 px).\nDefault: 32. Larger values make the boundary between plugins clearer. This does not apply between rows of the same plugin — see Row Gap for that.', 'Separator Width') }}</label>
<input type="number"
id="vegas_separator_width"
name="vegas_separator_width"
@@ -420,6 +444,44 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Extra gap always added between rows contributed by the same plugin (0128 px).\nDefault: 8. This is a floor on top of Row Separation below, which does most of the work. Set both to 0 to butt rows directly together.', 'Row Gap') }}</label>
<input type="number"
id="vegas_intra_plugin_gap"
name="vegas_intra_plugin_gap"
value="{{ main_config.display.get('vegas_scroll', {}).get('intra_plugin_gap', 8) }}"
min="0"
max="128"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_min_content_separation" data-setting-key="display.vegas_scroll.min_content_separation">
<label for="vegas_min_content_separation" class="block text-sm font-medium text-gray-700">Row Separation (pixels){{ ui.help_tip('Blank space guaranteed between rows of the same plugin, measured from the actual content rather than added blindly (0256 px).\nDefault: 24. Rows already carrying wide margins get nothing added; rows drawn right up to their own edges — sports score cards, for instance — get the full amount, so they no longer look like they are touching. Raise it if items still feel cramped.', 'Row Separation') }}</label>
<input type="number"
id="vegas_min_content_separation"
name="vegas_min_content_separation"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_content_separation', 24) }}"
min="0"
max="256"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_render_width_pct" data-setting-key="display.vegas_scroll.render_width_pct">
<label for="vegas_render_width_pct" class="block text-sm font-medium text-gray-700">Plugin Render Width (%){{ ui.help_tip('How much of the screen width each plugin is told it has while drawing for the ticker (10100%).\nDefault: 100 (unchanged). Lowering it makes plugins choose a tighter layout rather than being cropped — a weather forecast becomes narrow cards instead of five columns spread across the panel. Useful on wide displays. Override per plugin with the vegas_width_pct setting in that plugin\'s own configuration.', 'Plugin Render Width') }}</label>
<input type="number"
id="vegas_render_width_pct"
name="vegas_render_width_pct"
value="{{ main_config.display.get('vegas_scroll', {}).get('render_width_pct', 100) }}"
min="10"
max="100"
step="5"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
@@ -440,6 +502,194 @@
</div>
</div>
<!-- Cycle Pacing -->
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Cycle Pacing</h4>
<p class="text-sm text-gray-600 mb-3">How long one pass through the ticker lasts, and how many plugins it covers.</p>
<div class="form-group mb-4" id="setting-display-vegas_continuous_scroll" data-setting-key="display.vegas_scroll.continuous_scroll">
<label class="flex items-center">
<input type="checkbox"
id="vegas_continuous_scroll"
name="vegas_continuous_scroll"
{% if main_config.display.get('vegas_scroll', {}).get('continuous_scroll', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Scroll continuously between groups{{ ui.help_tip('Keep one endless strip, extending it with the next group of plugins as the scroll approaches the end, so they simply arrive from the right. Default: on.\nWith this off the ticker builds a fresh strip and swaps it in, which stops the motion, replaces everything at once and restarts with the screen already full — a freeze, a flash and a jump.', 'Continuous Scroll') }}</span>
</label>
</div>
<div class="form-group mb-4" id="setting-display-vegas_smooth_scroll" data-setting-key="display.vegas_scroll.smooth_scroll">
<label class="flex items-center">
<input type="checkbox"
id="vegas_smooth_scroll"
name="vegas_smooth_scroll"
{% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Smooth sub-pixel motion{{ ui.help_tip('Blend between neighbouring pixel positions so the ticker moves once per rendered frame instead of once per pixel. Default: on.\nWithout it, motion happens only as often as the scroll speed in pixels per second — at 50 px/s that is 50 steps a second however fast the display renders, which reads as a slight judder. The trade is that text softens very slightly horizontally, since each frame blends two positions. Turn it off if you prefer maximum crispness.', 'Smooth Scrolling') }}</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.010.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>
<input type="number"
id="vegas_extend_threshold_screens"
name="vegas_extend_threshold_screens"
value="{{ main_config.display.get('vegas_scroll', {}).get('extend_threshold_screens', 2.0) }}"
min="1"
max="10"
step="0.5"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_plugins_per_cycle" data-setting-key="display.vegas_scroll.plugins_per_cycle">
<label for="vegas_plugins_per_cycle" class="block text-sm font-medium text-gray-700">Plugins Per Cycle{{ ui.help_tip('How many plugins are composed into one pass of the ticker (150).\nDefault: 6. Higher means more variety before the ticker restarts, and fewer recompose pauses. Lower means each plugin comes around sooner.', 'Plugins Per Cycle') }}</label>
<input type="number"
id="vegas_plugins_per_cycle"
name="vegas_plugins_per_cycle"
value="{{ main_config.display.get('vegas_scroll', {}).get('plugins_per_cycle', 6) }}"
min="1"
max="50"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_overflow_mode" data-setting-key="display.vegas_scroll.overflow_mode">
<label for="vegas_overflow_mode" class="block text-sm font-medium text-gray-700">When A Plugin Is Too Wide{{ ui.help_tip('What to do when a plugin has more content than its width allowance.\nRotate through it: show a different slice each time round, so everything is seen eventually. Right for interchangeable items like news headlines, odds or stock prices.\nShow the start only: always display from the beginning and drop the rest. Right for ordered content — a league table that shows ranks 1-6 and then resumes at 7 two rotations later reads as out of order.\nDefault: rotate. Override for one plugin with vegas_overflow in its own settings.', 'Overflow Handling') }}</label>
<select id="vegas_overflow_mode" name="vegas_overflow_mode" class="form-control">
<option value="rotate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'rotate' %}selected{% endif %}>Rotate through it (default)</option>
<option value="truncate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'truncate' %}selected{% endif %}>Show the start only</option>
</select>
</div>
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (020).\nDefault: 0 (no limit) — every plugin shows all of its content and always starts at the beginning.\nSet a limit to stop one long ticker holding the display for minutes: it is cut to this width and the remainder shown on later cycles. The trade-off is that such a plugin then resumes mid-content on each appearance instead of starting fresh.', 'Max Plugin Width') }}</label>
<input type="number"
id="vegas_max_plugin_width_ratio"
name="vegas_max_plugin_width_ratio"
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 0.0) }}"
min="0"
max="20"
step="0.5"
class="form-control">
</div>
</div>
<div class="form-group mt-4" id="setting-display-vegas_dynamic_duration_enabled" data-setting-key="display.vegas_scroll.dynamic_duration_enabled">
<label class="flex items-center">
<input type="checkbox"
id="vegas_dynamic_duration_enabled"
name="vegas_dynamic_duration_enabled"
{% if main_config.display.get('vegas_scroll', {}).get('dynamic_duration_enabled', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Size cycle time to the content{{ ui.help_tip('When on, each cycle runs just long enough to scroll all its content past, clamped to the min and max below.\nWhen off, the max is always used. Default: on.', 'Dynamic Cycle Duration') }}</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div class="form-group" id="setting-display-vegas_min_cycle_duration" data-setting-key="display.vegas_scroll.min_cycle_duration">
<label for="vegas_min_cycle_duration" class="block text-sm font-medium text-gray-700">Min Cycle Time (seconds){{ ui.help_tip('Shortest a single ticker pass may last (53600 s).\nDefault: 60.', 'Min Cycle Time') }}</label>
<input type="number"
id="vegas_min_cycle_duration"
name="vegas_min_cycle_duration"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cycle_duration', 60) }}"
min="5"
max="3600"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_max_cycle_duration" data-setting-key="display.vegas_scroll.max_cycle_duration">
<label for="vegas_max_cycle_duration" class="block text-sm font-medium text-gray-700">Max Cycle Time (seconds){{ ui.help_tip('Longest a single ticker pass may last before it restarts with fresh content (103600 s).\nThis is the setting that caps total Vegas scroll time. Default: 240. Lower it if the ticker feels like it takes too long to come back around.', 'Max Cycle Time') }}</label>
<input type="number"
id="vegas_max_cycle_duration"
name="vegas_max_cycle_duration"
value="{{ main_config.display.get('vegas_scroll', {}).get('max_cycle_duration', 240) }}"
min="10"
max="3600"
class="form-control">
</div>
</div>
</div>
<!-- Dead Space -->
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Dead Space</h4>
<p class="text-sm text-gray-600 mb-3">Plugins that draw onto a full-screen canvas contribute all the empty space around their content. Trimming reclaims it so the ticker stays full.</p>
<div class="form-group mb-4" id="setting-display-vegas_auto_trim" data-setting-key="display.vegas_scroll.auto_trim">
<label class="flex items-center">
<input type="checkbox"
id="vegas_auto_trim"
name="vegas_auto_trim"
{% if main_config.display.get('vegas_scroll', {}).get('auto_trim', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Trim empty edges from plugin content{{ ui.help_tip('Crops blank columns from the left and right of each plugin block before it enters the ticker. Space between two pieces of content inside a block is left alone, so layouts are not altered. Default: on.', 'Auto Trim') }}</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_content_padding" data-setting-key="display.vegas_scroll.content_padding">
<label for="vegas_content_padding" class="block text-sm font-medium text-gray-700">Content Padding (pixels){{ ui.help_tip('Blank columns kept either side of trimmed content, so it does not butt against the separator (0128 px).\nDefault: 8.', 'Content Padding') }}</label>
<input type="number"
id="vegas_content_padding"
name="vegas_content_padding"
value="{{ main_config.display.get('vegas_scroll', {}).get('content_padding', 8) }}"
min="0"
max="128"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_lead_in_width" data-setting-key="display.vegas_scroll.lead_in_width">
<label for="vegas_lead_in_width" class="block text-sm font-medium text-gray-700">Lead-In Gap (pixels){{ ui.help_tip('Blank space before the first plugin of each cycle (02048 px).\nDefault: 0. Anything approaching your screen width reads as the display switching off at the start of every cycle.', 'Lead-In Gap') }}</label>
<input type="number"
id="vegas_lead_in_width"
name="vegas_lead_in_width"
value="{{ main_config.display.get('vegas_scroll', {}).get('lead_in_width', 0) }}"
min="0"
max="2048"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="form-group" id="setting-display-vegas_trim_threshold" data-setting-key="display.vegas_scroll.trim_threshold">
<label for="vegas_trim_threshold" class="block text-sm font-medium text-gray-700">Trim Threshold{{ ui.help_tip('How bright a pixel must be to count as content rather than empty space (0254).\nDefault: 10, which ignores the near-black noise left by image compression. Raise it if very dark artwork is being kept; lower it if dark detail is being cropped.', 'Trim Threshold') }}</label>
<input type="number"
id="vegas_trim_threshold"
name="vegas_trim_threshold"
value="{{ main_config.display.get('vegas_scroll', {}).get('trim_threshold', 10) }}"
min="0"
max="254"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_min_plugin_width" data-setting-key="display.vegas_scroll.min_plugin_width">
<label for="vegas_min_plugin_width" class="block text-sm font-medium text-gray-700">Min Plugin Width (pixels){{ ui.help_tip('Plugin blocks narrower than this after trimming are skipped for that cycle (0512 px).\nDefault: 8. Raise it to hide plugins showing only a tiny placeholder such as "No Data" until they have real content.', 'Min Plugin Width') }}</label>
<input type="number"
id="vegas_min_plugin_width"
name="vegas_min_plugin_width"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_plugin_width', 8) }}"
min="0"
max="512"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="form-group" id="setting-display-vegas_min_cut_gap" data-setting-key="display.vegas_scroll.min_cut_gap">
<label for="vegas_min_cut_gap" class="block text-sm font-medium text-gray-700">Min Cut Gap (pixels){{ ui.help_tip('When a plugin is too wide for its share of a cycle and has to be narrowed, the cut is only made where there is at least this much blank space (1128 px).\nDefault: 6. The gaps between letters are about 1px wide, so a smaller value lets a cut land inside a word and orphan its last letter into the next cycle. Raise it if cuts still land awkwardly. Continuous images such as maps have no gaps and are cut to fit regardless.', 'Min Cut Gap') }}</label>
<input type="number"
id="vegas_min_cut_gap"
name="vegas_min_cut_gap"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cut_gap', 6) }}"
min="1"
max="128"
class="form-control">
</div>
</div>
</div>
<!-- Plugin Order Section -->
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4>
@@ -454,6 +704,52 @@
</div>
</div>
<!-- Double-Sided Display -->
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain &mdash; e.g. two 64&times;32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
<div class="form-group mb-4" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
<label class="flex items-center gap-2">
<input type="checkbox"
id="double_sided_enabled"
name="double_sided_enabled"
value="true"
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="text-sm font-medium text-gray-700">Enabled</span>
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
</label>
</div>
<!-- Hidden (not disabled) when the feature is off, so the values are
still submitted and round-trip through a save. -->
<div id="double_sided_settings"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
{% if not main_config.display.get('double_sided', {}).get('enabled') %}style="display: none;"{% endif %}>
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (28).\nWhen enabled, this must divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
<input type="number"
id="double_sided_copies"
name="double_sided_copies"
value="{{ main_config.display.get('double_sided', {}).get('copies', 2) }}"
min="2"
max="8"
class="form-control">
</div>
<div class="form-group" id="setting-display-double_sided_axis" data-setting-key="display.double_sided.axis">
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis{{ ui.help_tip('Direction the display is divided into copies.\nHorizontal splits along the chained panels (side by side); Vertical splits along parallel chains (stacked).', 'Split Axis') }}</label>
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal &mdash; chained panels (side by side)</option>
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical &mdash; parallel chains (stacked)</option>
</select>
</div>
</div>
</div>
<!-- Multi-Display Sync Settings -->
<div class="bg-gray-50 rounded-lg p-4 mt-6">
<div class="flex items-center justify-between mb-4">
@@ -549,6 +845,28 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
};
}
// Report the outcome of a display-settings save. XMLHttpRequest has no
// `responseJSON` (that's a jQuery property) — read `responseText` and the real
// status code, otherwise a failed save reports success.
window.showDisplaySaveResult = function(xhr) {
// Only 2xx counts as saved. A network failure reports status 0, which any
// `>= 400` test would wave through as success.
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
let message = httpSuccess
? 'Display settings saved'
: 'Display settings were not saved. Check your connection and try again.';
let status = httpSuccess ? 'success' : 'error';
try {
const data = JSON.parse(xhr.responseText);
if (data.message) message = data.message;
// A body can refine a successful verdict but never overturn a failed one.
if (httpSuccess && data.status) status = data.status;
} catch {
// Non-JSON body — fall back to the status-code verdict above.
}
showNotification(message, status);
};
// Vegas Scroll Mode Settings
(function() {
// Escape HTML to prevent XSS
@@ -573,6 +891,18 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
});
}
// Double-sided: copies/axis only mean anything while the feature is on.
// Hidden rather than disabled so the fields keep submitting and the server
// still sees an 'off' state to persist.
const doubleSidedCheckbox = document.getElementById('double_sided_enabled');
const doubleSidedSettings = document.getElementById('double_sided_settings');
if (doubleSidedCheckbox && doubleSidedSettings) {
doubleSidedCheckbox.addEventListener('change', function() {
doubleSidedSettings.style.display = this.checked ? 'grid' : 'none';
});
}
// Update scroll speed display
const scrollSpeedSlider = document.getElementById('vegas_scroll_speed');
const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value');
@@ -583,183 +913,32 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
});
}
// Initialize plugin order list
function initPluginOrderList() {
// Initialize plugin order list via the shared drag-and-drop module
// (static/v3/js/widgets/plugin-order-list.js) — the same component the
// Durations tab uses for the primary rotation order.
function initPluginOrderList(attempt) {
const container = document.getElementById('vegas_plugin_order');
if (!container) return;
// Fetch available plugins
fetch('/api/v3/plugins/installed')
.then(response => response.json())
.then(data => {
// Handle both {data: {plugins: []}} and {plugins: []} response formats
const allPlugins = data.data?.plugins || data.plugins || [];
if (!allPlugins || allPlugins.length === 0) {
container.innerHTML = '<p class="text-sm text-gray-500 italic">No plugins available</p>';
return;
}
// Get current order and exclusions
const orderInput = document.getElementById('vegas_plugin_order_value');
const excludedInput = document.getElementById('vegas_excluded_plugins_value');
let currentOrder = [];
let excluded = [];
try {
currentOrder = JSON.parse(orderInput.value || '[]');
excluded = JSON.parse(excludedInput.value || '[]');
} catch (e) {
console.error('Error parsing vegas config:', e);
}
// Build ordered plugin list (only enabled plugins)
const plugins = allPlugins.filter(p => p.enabled);
const orderedPlugins = [];
// First add plugins in current order
currentOrder.forEach(id => {
const plugin = plugins.find(p => p.id === id);
if (plugin) orderedPlugins.push(plugin);
});
// Then add remaining plugins
plugins.forEach(plugin => {
if (!orderedPlugins.find(p => p.id === plugin.id)) {
orderedPlugins.push(plugin);
}
});
// Build HTML with display mode indicators
let html = '';
orderedPlugins.forEach((plugin, index) => {
const isExcluded = excluded.includes(plugin.id);
// Determine display mode (from plugin config or default)
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
const modeLabels = {
'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' },
'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' },
'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }
};
const modeInfo = modeLabels[vegasMode] || modeLabels['fixed'];
// Escape plugin metadata to prevent XSS
const safePluginId = escapeAttr(plugin.id);
const safePluginName = escapeHtml(plugin.name || plugin.id);
html += `
<div class="flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move vegas-plugin-item"
data-plugin-id="${safePluginId}" draggable="true">
<i class="fas fa-grip-vertical text-gray-400 mr-3"></i>
<label class="flex items-center flex-1">
<input type="checkbox"
class="vegas-plugin-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2"
${!isExcluded ? 'checked' : ''}>
<span class="text-sm font-medium text-gray-700">${safePluginName}</span>
</label>
<span class="text-xs ${modeInfo.color} ml-2" title="Vegas display mode: ${modeInfo.label}">
<i class="fas ${modeInfo.icon} mr-1"></i>${modeInfo.label}
</span>
</div>
`;
});
container.innerHTML = html || '<p class="text-sm text-gray-500 italic">No enabled plugins</p>';
// Setup drag and drop
setupDragAndDrop(container);
// Setup checkbox handlers
container.querySelectorAll('.vegas-plugin-include').forEach(checkbox => {
checkbox.addEventListener('change', updatePluginConfig);
});
// Initialize hidden inputs with current state
updatePluginConfig();
})
.catch(error => {
console.error('Error fetching plugins:', error);
container.innerHTML = '<p class="text-sm text-red-500">Error loading plugins</p>';
});
}
function setupDragAndDrop(container) {
let draggedItem = null;
container.querySelectorAll('.vegas-plugin-item').forEach(item => {
item.addEventListener('dragstart', function(e) {
draggedItem = this;
this.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', function() {
this.style.opacity = '1';
draggedItem = null;
updatePluginConfig();
});
item.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = this.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
this.style.borderTop = '2px solid #3b82f6';
this.style.borderBottom = '';
} else {
this.style.borderBottom = '2px solid #3b82f6';
this.style.borderTop = '';
}
});
item.addEventListener('dragleave', function() {
this.style.borderTop = '';
this.style.borderBottom = '';
});
item.addEventListener('drop', function(e) {
e.preventDefault();
this.style.borderTop = '';
this.style.borderBottom = '';
if (draggedItem && draggedItem !== this) {
const rect = this.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
container.insertBefore(draggedItem, this);
} else {
container.insertBefore(draggedItem, this.nextSibling);
}
}
});
});
}
function updatePluginConfig() {
const container = document.getElementById('vegas_plugin_order');
const orderInput = document.getElementById('vegas_plugin_order_value');
const excludedInput = document.getElementById('vegas_excluded_plugins_value');
if (!container || !orderInput || !excludedInput) return;
const order = [];
const excluded = [];
container.querySelectorAll('.vegas-plugin-item').forEach(item => {
const pluginId = item.dataset.pluginId;
const checkbox = item.querySelector('.vegas-plugin-include');
order.push(pluginId);
if (checkbox && !checkbox.checked) {
excluded.push(pluginId);
if (!window.PluginOrderList) {
// Widget script is deferred; retry briefly, then surface a real
// error instead of waiting forever.
if ((attempt || 0) < 50) {
setTimeout(function() { initPluginOrderList((attempt || 0) + 1); }, 100);
} else {
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
window.PluginOrderList.init({
containerId: 'vegas_plugin_order',
orderInputId: 'vegas_plugin_order_value',
excludedInputId: 'vegas_excluded_plugins_value',
showVegasModeBadge: true
});
orderInput.value = JSON.stringify(order);
excludedInput.value = JSON.stringify(excluded);
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPluginOrderList);
@@ -1,8 +1,8 @@
{% import 'v3/partials/_macros.html' as ui %}
<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">Display Durations</h2>
<p class="mt-1 text-sm text-gray-600">Configure how long each screen is shown before switching. Values in seconds.</p>
<h2 class="text-lg font-semibold text-gray-900">Rotation &amp; Durations</h2>
<p class="mt-1 text-sm text-gray-600">Set the order plugins rotate on the display and how long each screen is shown. Durations are in seconds.</p>
</div>
{{ ui.settings_filter() }}
@@ -16,22 +16,53 @@
novalidate
onsubmit="fixInvalidNumberInputs(this); return true;">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for key, value in main_config.display.display_durations.items() %}
<div class="form-group" id="setting-durations-{{ key }}" data-setting-key="display.display_durations.{{ key }}">
<label for="duration_{{ key }}" class="block text-sm font-medium text-gray-700">
{{ key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5600. Currently ' ~ value ~ 's.', key | replace('_', ' ') | title) }}
</label>
<input type="number"
id="duration_{{ key }}"
name="{{ key }}"
value="{{ value }}"
min="5"
max="600"
class="form-control">
<!-- Primary rotation order: drag to reorder which plugin shows first,
second, ... in the normal display rotation. Saved as
display.plugin_rotation_order and applied by the display
controller on startup and live plugin enable/disable. -->
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-md font-medium text-gray-900 mb-1">Rotation Order</h3>
<p class="text-sm text-gray-600 mb-3">Drag plugins to set the order they rotate on the display. Each plugin's screens keep their own order within its turn. Takes effect after saving and restarting the display.</p>
<div id="rotation_plugin_order" class="space-y-2 bg-white rounded-lg p-3 border border-gray-200">
<p class="text-sm text-gray-500 italic">Loading plugins…</p>
</div>
<input type="hidden" id="rotation_plugin_order_value" name="plugin_rotation_order"
value='{{ main_config.display.get("plugin_rotation_order", [])|tojson }}'>
</div>
{% if duration_groups %}
<div class="bg-gray-50 rounded-lg p-4 space-y-5">
<div>
<h3 class="text-md font-medium text-gray-900 mb-1">Screen Durations</h3>
<p class="text-sm text-gray-600">How long each screen stays on before rotating to the next one, in seconds (5&ndash;600, default 30).</p>
</div>
{% for group in duration_groups %}
<div>
<h4 class="text-sm font-semibold text-gray-800 mb-2">{{ group.plugin_name }}</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for mode in group.modes %}
<div class="form-group" id="setting-durations-{{ mode.key }}" data-setting-key="display.display_durations.{{ mode.key }}">
<label for="duration__{{ mode.key }}" class="block text-sm font-medium text-gray-700">
{{ mode.key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (mode.key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5600. Currently ' ~ mode.value ~ 's.', mode.key | replace('_', ' ') | title) }}
</label>
<input type="number"
id="duration__{{ mode.key }}"
name="duration__{{ mode.key }}"
value="{{ mode.value }}"
min="5"
max="600"
class="form-control">
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm text-gray-500 italic">No enabled plugins found &mdash; enable a plugin in the Plugin Manager to set its screen durations here.</p>
</div>
{% endif %}
<!-- Submit Button -->
<div class="flex justify-end">
@@ -43,3 +74,34 @@
</div>
</form>
</div>
<script>
(function () {
// Shared drag-and-drop plugin list (static/v3/js/widgets/plugin-order-list.js,
// same module the Vegas Scroll section uses).
function initRotationOrderList(attempt) {
const container = document.getElementById('rotation_plugin_order');
if (!container) return;
if (!window.PluginOrderList) {
// Widget script is deferred; retry briefly, then surface a real
// error instead of showing "Loading…" forever.
if ((attempt || 0) < 50) {
setTimeout(function() { initRotationOrderList((attempt || 0) + 1); }, 100);
} else {
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
window.PluginOrderList.init({
containerId: 'rotation_plugin_order',
orderInputId: 'rotation_plugin_order_value'
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initRotationOrderList);
} else {
initRotationOrderList();
}
}());
</script>
@@ -49,6 +49,18 @@
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
<div id="timezone_container" class="mt-1"></div>
</div>
<!-- Scroll frame rate (device-wide) -->
<div class="form-group" id="setting-general-target-fps" data-setting-key="target_fps">
<label for="target_fps" class="block text-sm font-medium text-gray-700">Scroll Frame Rate{{ ui.help_tip('Frames per second for scrolling content, applied across plugins that scroll.\nHigher is smoother but uses more CPU; lower frees CPU but looks steppier.\nRange 30-200. Default: 100.', 'Scroll Frame Rate') }}</label>
<input type="number"
id="target_fps"
name="target_fps"
value="{{ main_config.target_fps or 100 }}"
min="30"
max="200"
class="form-control">
</div>
<script>
(function() {
// Track if already initialized to prevent re-render
@@ -83,7 +95,7 @@
<!-- Location Information -->
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
<div class="form-group" id="setting-general-city" data-setting-key="location.city">
<label for="city" class="block text-sm font-medium text-gray-700">City{{ ui.help_tip('City used for weather, sunrise/sunset, and other location-based content.\nExample: Dallas.', 'City') }}</label>
<label for="city" class="block text-sm font-medium text-gray-700">City{{ ui.help_tip('City used for weather, sunrise/sunset, radar, and other location-based content.\nExample: Kansas City.\nUsed as the default for the location_city setting on plugins that have one; a value saved on the plugin itself overrides it.', 'City') }}</label>
<input type="text"
id="city"
name="city"
@@ -92,7 +104,7 @@
</div>
<div class="form-group" id="setting-general-state" data-setting-key="location.state">
<label for="state" class="block text-sm font-medium text-gray-700">State{{ ui.help_tip('State or region for your location.\nExample: Texas. Improves location-lookup accuracy.', 'State') }}</label>
<label for="state" class="block text-sm font-medium text-gray-700">State{{ ui.help_tip('State or region for your location.\nExample: Missouri. Improves location-lookup accuracy.\nUsed as the default for the location_state setting on plugins that have one.', 'State') }}</label>
<input type="text"
id="state"
name="state"
@@ -101,7 +113,7 @@
</div>
<div class="form-group" id="setting-general-country" data-setting-key="location.country">
<label for="country" class="block text-sm font-medium text-gray-700">Country{{ ui.help_tip('Country code or name for your location.\nExample: US. Used with City and State for weather and geolocation.', 'Country') }}</label>
<label for="country" class="block text-sm font-medium text-gray-700">Country{{ ui.help_tip('Country code or name for your location.\nExample: US. Used with City and State for weather, radar, and geolocation.\nUsed as the default for the location_country setting on plugins that have one.', 'Country') }}</label>
<input type="text"
id="country"
name="country"
+167 -31
View File
@@ -4,6 +4,17 @@
<p class="mt-1 text-sm text-gray-600">View real-time logs from the LED matrix service for troubleshooting.</p>
</div>
<!-- Currently displayed plugin -->
<div id="current-plugin-banner" class="hidden mb-4 flex items-center gap-2 text-sm bg-blue-50 border border-blue-200 text-blue-800 rounded-lg px-3 py-2">
<i class="fas fa-tv"></i>
<span>Now showing: <strong id="current-plugin-mode">-</strong>
<span id="current-plugin-id-wrap" class="hidden">(plugin: <code id="current-plugin-id" class="bg-blue-100 px-1 rounded"></code>)</span>
</span>
<button id="current-plugin-filter-btn" class="hidden ml-auto btn bg-blue-600 hover:bg-blue-700 text-white px-2 py-0.5 rounded text-xs">
Filter to this plugin
</button>
</div>
<!-- Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
@@ -27,6 +38,11 @@
<option value="INFO">Info & Above</option>
</select>
<!-- Plugin Filter -->
<select id="log-plugin-filter" class="form-control text-sm">
<option value="">All Plugins</option>
</select>
<!-- Search -->
<div class="relative">
<input type="text" id="log-search" placeholder="Search logs..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
@@ -139,6 +155,7 @@ window._filteredLogs = [];
const realtimeToggle = document.getElementById('log-realtime-toggle');
const refreshBtn = document.getElementById('refresh-logs-btn');
const levelFilter = document.getElementById('log-level-filter');
const pluginFilter = document.getElementById('log-plugin-filter');
const searchInput = document.getElementById('log-search');
const autoscrollToggle = document.getElementById('log-autoscroll');
const clearBtn = document.getElementById('clear-logs-btn');
@@ -160,6 +177,11 @@ window._filteredLogs = [];
levelFilter.parentNode.replaceChild(newFilter, levelFilter);
newFilter.addEventListener('change', filterLogs);
}
if (pluginFilter) {
const newFilter = pluginFilter.cloneNode(true);
pluginFilter.parentNode.replaceChild(newFilter, pluginFilter);
newFilter.addEventListener('change', filterLogs);
}
if (searchInput) {
const newInput = searchInput.cloneNode(true);
searchInput.parentNode.replaceChild(newInput, searchInput);
@@ -180,6 +202,24 @@ window._filteredLogs = [];
downloadBtn.parentNode.replaceChild(newBtn, downloadBtn);
newBtn.addEventListener('click', downloadLogs);
}
const currentPluginFilterBtn = document.getElementById('current-plugin-filter-btn');
if (currentPluginFilterBtn) {
const newBtn = currentPluginFilterBtn.cloneNode(true);
currentPluginFilterBtn.parentNode.replaceChild(newBtn, currentPluginFilterBtn);
newBtn.addEventListener('click', function() {
const pluginFilterEl = document.getElementById('log-plugin-filter');
if (pluginFilterEl && window._currentPluginId) {
pluginFilterEl.value = window._currentPluginId;
filterLogs();
}
});
}
refreshCurrentPluginStatus();
if (window._currentPluginPollTimer) {
clearInterval(window._currentPluginPollTimer);
}
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
// Handle window resize for responsive height
window.addEventListener('resize', function() {
@@ -284,37 +324,25 @@ function processLogs(logsText, append = false) {
// Skip empty lines
if (!line.trim()) return;
// Try to parse journalctl format: "MMM DD HH:MM:SS hostname service[pid]: message"
// Example: "Oct 13 14:23:45 raspberrypi ledmatrix[1234]: INFO: Starting display"
// journalctl (--output=short-iso) emits: "YYYY-MM-DDTHH:MM:SS+ZZZZ hostname service[pid]: message"
// Example: "2024-01-15T10:23:45+0000 raspberrypi ledmatrix[1234]: INFO - plugin.nhl_scoreboard - [Plugin: nhl_scoreboard] Updated scores"
// Also accept the older syslog-style "MMM DD HH:MM:SS" timestamp for compatibility.
let timestamp = '';
let level = 'INFO';
let message = line;
// Extract timestamp (first part before hostname)
const timestampMatch = line.match(/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/);
if (timestampMatch) {
timestamp = timestampMatch[1];
// Find the message part (after service name and pid)
const messageMatch = line.match(/:\s*(.+)$/);
if (messageMatch) {
message = messageMatch[1];
// Detect log level from message
if (message.match(/\b(ERROR|CRITICAL|FATAL)\b/i)) {
level = 'ERROR';
} else if (message.match(/\b(WARNING|WARN)\b/i)) {
level = 'WARNING';
} else if (message.match(/\bDEBUG\b/i)) {
level = 'DEBUG';
} else if (message.match(/\bINFO\b/i)) {
level = 'INFO';
}
// Clean up level prefix from message if it exists
message = message.replace(/^(ERROR|WARNING|WARN|INFO|DEBUG):\s*/i, '');
}
let plugin = '';
let rest = null;
const isoMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:?\d{2}|Z))\s+(.*)$/);
const syslogMatch = !isoMatch && line.match(/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+(.*)$/);
if (isoMatch) {
timestamp = isoMatch[1];
rest = isoMatch[2];
} else if (syslogMatch) {
timestamp = syslogMatch[1];
rest = syslogMatch[2];
} else {
// If no timestamp, use current time
timestamp = new Date().toLocaleString('en-US', {
@@ -327,14 +355,58 @@ function processLogs(logsText, append = false) {
});
}
if (rest !== null) {
// Find the message part (after hostname + service name/pid)
const messageMatch = rest.match(/:\s*(.+)$/);
if (messageMatch) {
message = messageMatch[1];
// Detect log level from message
if (message.match(/\b(ERROR|CRITICAL|FATAL)\b/i)) {
level = 'ERROR';
} else if (message.match(/\b(WARNING|WARN)\b/i)) {
level = 'WARNING';
} else if (message.match(/\bDEBUG\b/i)) {
level = 'DEBUG';
} else if (message.match(/\bINFO\b/i)) {
level = 'INFO';
}
// Clean up level prefix from message if it exists
message = message.replace(/^(ERROR|WARNING|WARN|INFO|DEBUG)\s*[-:]\s*/i, '');
// journalctl already carries its own timestamp; strip the app's
// internal "YYYY-MM-DD HH:MM:SS.mmm - LEVEL - logger.name - "
// prefix (see ContextualFormatter in src/logging_config.py) so
// it isn't duplicated in the displayed message.
message = message.replace(
/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*-\s*(ERROR|WARNING|WARN|INFO|DEBUG|CRITICAL)\s*-\s*[\w.]+\s*-\s*/i,
''
);
// Extract the "[Plugin: <id>]" context tag emitted by
// ContextualFormatter for every plugin logger (see
// src/logging_config.py PluginLoggerAdapter), and pull it out
// of the displayed message into its own field.
const pluginMatch = message.match(/^\[Plugin:\s*([^\]]+)\]\s*/);
if (pluginMatch) {
plugin = pluginMatch[1].trim();
message = message.replace(pluginMatch[0], '');
}
} else {
message = rest;
}
}
const logEntry = {
timestamp: timestamp,
level: level,
plugin: plugin,
message: message,
raw: line,
id: Date.now() + Math.random()
};
// Don't add duplicate entries when appending
if (!append || !window._allLogs.find(log => log.raw === line)) {
window._allLogs.push(logEntry);
@@ -346,9 +418,26 @@ function processLogs(logsText, append = false) {
window._allLogs = window._allLogs.slice(-window._MAX_LOGS);
}
updatePluginFilterOptions();
filterLogs();
}
function updatePluginFilterOptions() {
const pluginFilterEl = document.getElementById('log-plugin-filter');
if (!pluginFilterEl) return;
const previousValue = pluginFilterEl.value;
const plugins = Array.from(new Set(window._allLogs.map(log => log.plugin).filter(Boolean))).sort();
pluginFilterEl.innerHTML = '<option value="">All Plugins</option>' +
plugins.map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
// Restore previous selection if it's still a valid option
if (previousValue && plugins.includes(previousValue)) {
pluginFilterEl.value = previousValue;
}
}
function renderLogs() {
if (window._filteredLogs.length === 0) {
showEmptyState();
@@ -364,10 +453,14 @@ function renderLogs() {
window._filteredLogs.forEach(log => {
const logElement = document.createElement('div');
logElement.className = `log-entry py-1 px-2 hover:bg-gray-800 rounded transition-colors duration-150 ${getLogLevelClass(log.level)}`;
const pluginBadge = log.plugin
? `<span class="log-plugin flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold bg-purple-700 text-white" title="Plugin: ${escapeHtml(log.plugin)}">${escapeHtml(log.plugin)}</span>`
: '';
logElement.innerHTML = `
<div class="flex items-start gap-3 text-xs font-mono">
<span class="log-timestamp text-gray-400 flex-shrink-0 w-32">${escapeHtml(log.timestamp)}</span>
<span class="log-level flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${getLogLevelBadgeClass(log.level)}">${log.level}</span>
${pluginBadge}
<span class="log-message flex-1 ${getLogLevelTextClass(log.level)} break-words">${escapeHtml(log.message)}</span>
</div>
`;
@@ -410,10 +503,12 @@ function getLogLevelTextClass(level) {
function filterLogs() {
const levelFilterEl = document.getElementById('log-level-filter');
const pluginFilterEl = document.getElementById('log-plugin-filter');
const searchEl = document.getElementById('log-search');
if (!levelFilterEl || !searchEl) return;
const levelFilter = levelFilterEl.value;
const pluginFilter = pluginFilterEl ? pluginFilterEl.value : '';
const searchTerm = searchEl.value.toLowerCase();
window._filteredLogs = window._allLogs.filter(log => {
@@ -430,8 +525,14 @@ function filterLogs() {
}
}
// Plugin filter
if (pluginFilter && log.plugin !== pluginFilter) {
return false;
}
// Search filter
if (searchTerm && !log.message.toLowerCase().includes(searchTerm)) {
if (searchTerm && !log.message.toLowerCase().includes(searchTerm) &&
!(log.plugin && log.plugin.toLowerCase().includes(searchTerm))) {
return false;
}
@@ -617,10 +718,45 @@ function escapeHtml(text) {
return div.innerHTML;
}
function refreshCurrentPluginStatus() {
fetch('/api/v3/display/current-status')
.then(response => response.json())
.then(data => {
if (data.status !== 'success' || !data.data) return;
const state = data.data;
const banner = document.getElementById('current-plugin-banner');
const modeEl = document.getElementById('current-plugin-mode');
const idWrap = document.getElementById('current-plugin-id-wrap');
const idEl = document.getElementById('current-plugin-id');
const filterBtn = document.getElementById('current-plugin-filter-btn');
if (!banner || !modeEl) return;
window._currentPluginId = state.plugin_id || null;
modeEl.textContent = state.mode || 'unknown';
if (state.plugin_id) {
idEl.textContent = state.plugin_id;
idWrap.classList.remove('hidden');
if (filterBtn) filterBtn.classList.remove('hidden');
} else {
idWrap.classList.add('hidden');
if (filterBtn) filterBtn.classList.add('hidden');
}
banner.classList.remove('hidden');
})
.catch(() => {
// Silently ignore - banner just stays hidden/stale
});
}
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (window._logsEventSource) {
window._logsEventSource.close();
}
if (window._currentPluginPollTimer) {
clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = null;
}
});
</script>
@@ -61,6 +61,234 @@
}());
</script>
<!-- Getting Started checklist: non-gating, dismissible (localStorage), items
auto-check from existing config/endpoints — no new persisted state.
The timezone step is verified against the browser's own zone rather than
compared to the shipped default; see the data-check="timezone" block below
for why. -->
{% set _hw = main_config.display.hardware if main_config and main_config.display else {} %}
{% set _hw_done = (_hw.rows or 0) > 0 and (_hw.cols or 0) > 0 and (_hw.chain_length or 0) > 0 %}
{% set _loc = main_config.location if main_config and main_config.location else {} %}
{% set _tz = (main_config.timezone if main_config else '') or '' %}
<div id="getting-started-card" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4" style="display:none" role="region" aria-label="Getting started checklist">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-semibold text-blue-900"><i class="fas fa-rocket mr-1"></i>Getting Started</p>
<p class="text-xs text-blue-700 mt-0.5 mb-2">A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.</p>
<ul class="space-y-1 text-sm" id="getting-started-items">
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="timezone" data-tz="{{ _tz }}" data-tab="general">
<i class="far fa-square mr-2"></i>Set your timezone{% if _tz %} — currently {{ _tz }}{% if _loc.city %}, {{ _loc.city }}{% endif %}{% endif %} (General tab)<span data-gs-tz-note class="text-xs"></span></button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="installed" data-tab="plugins">
<i class="far fa-square mr-2"></i>Install a plugin from the Plugin Store</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="enabled" data-tab="plugins">
<i class="far fa-square mr-2"></i>Enable a plugin</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="configured" data-tab="plugins">
<i class="far fa-square mr-2"></i>Configure it (each plugin gets its own tab)</button></li>
</ul>
</div>
<button type="button" onclick="window.dismissGettingStarted()" class="ml-4 flex-shrink-0 text-blue-400 hover:text-blue-600" aria-label="Dismiss getting started checklist">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<script>
(function () {
var KEY = 'ledmatrix-getting-started-dismissed';
var MANUAL_KEY = 'ledmatrix-getting-started-manual';
var card = document.getElementById('getting-started-card');
if (!card) return;
try { if (localStorage.getItem(KEY) === '1') return; } catch (e) {}
card.style.display = 'block';
// Manual per-item overrides: the auto-detection is heuristic (a value
// saved AT its default — e.g. a user genuinely in the default timezone —
// reads as "not done"), so clicking an item's checkbox marks it done by
// hand, persisted per browser.
var manual = {};
try { manual = JSON.parse(localStorage.getItem(MANUAL_KEY) || '{}') || {}; } catch (e) {}
function saveManual() {
try { localStorage.setItem(MANUAL_KEY, JSON.stringify(manual)); } catch (e) {}
}
function setDone(btn, done) {
btn.dataset.done = done ? '1' : '0';
var icon = btn.querySelector('i');
if (icon) { icon.className = done ? 'fas fa-check-square mr-2 text-green-600' : 'far fa-square mr-2'; }
btn.classList.toggle('text-gray-500', done);
btn.classList.toggle('line-through', done);
}
// Once every step is done (auto-detected or manually checked), the card
// has served its purpose — hide it without requiring an explicit dismiss.
function maybeAutoHide() {
var items = card.querySelectorAll('.gs-item');
for (var i = 0; i < items.length; i++) {
if (items[i].dataset.done !== '1') return;
}
card.style.display = 'none';
}
function markDone(btn) {
if (!btn) return;
setDone(btn, true);
maybeAutoHide();
}
// Apply server-derived + manual states, wire deep links (same app-data
// access pattern as settings-search.js). Clicking the checkbox icon
// toggles manual done; clicking the text deep-links to the tab.
Array.prototype.forEach.call(card.querySelectorAll('.gs-item'), function (btn, idx) {
if (manual[idx] === 1) btn.dataset.done = '1';
if (btn.dataset.done === '1') setDone(btn, true);
btn.addEventListener('click', function (ev) {
var icon = btn.querySelector('i');
if (icon && ev.target === icon) {
var nowDone = btn.dataset.done !== '1';
setDone(btn, nowDone);
manual[idx] = nowDone ? 1 : 0;
saveManual();
if (nowDone) maybeAutoHide();
return;
}
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
// Same two-tier resolution as settings-search.js's getAppData():
// _x_dataStack on current Alpine, __x.$data as an older-API fallback.
var data = appEl && ((appEl._x_dataStack && appEl._x_dataStack[0]) ||
(appEl.__x && appEl.__x.$data));
if (data) {
data.activeTab = btn.dataset.tab;
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
}
});
});
maybeAutoHide();
// Timezone: verified against the browser's own zone.
//
// This step used to tick when the saved timezone differed from the value
// config.template.json ships (America/New_York), with the saved city
// OR-ed in. Two things were wrong with that. "Differs from the default"
// answers "did somebody edit this?", but what the checklist needs to know
// is whether the value is RIGHT — so anyone who genuinely lives in the
// default zone could never satisfy it and the card nagged forever. And
// the city has no bearing on whether the timezone is set: because the two
// were OR-ed, saving a city ticked the step off with the timezone still
// wrong, which is the direction that actually breaks displays (event
// times render in the wrong zone).
//
// The browser already knows its zone, so compare against that: no new
// persisted state, no network, and it catches the reverse case too — a
// panel still set to the old zone after a move now stays unticked, where
// the old test ticked it the moment the value stopped being the default.
function sameZone(a, b) {
if (a === b) return true;
// Compare the wall-clock time each zone yields, not the identifiers:
// aliases (Asia/Calcutta vs Asia/Kolkata, Europe/Kiev vs Europe/Kyiv)
// name one zone and must not read as a mismatch.
//
// Sampled at three instants, all of which have to agree. Checking only
// now is not enough: America/New_York and America/Lima hold the same
// offset all winter, so a panel set to the wrong one of those would
// tick in January and then run an hour off from March. Mid-January and
// mid-July sit either side of DST in both hemispheres, so only zones
// that agree year-round match -- while Toronto still matches New York,
// which is right, since either renders the same times.
try {
var now = new Date();
var year = now.getUTCFullYear();
var instants = [now,
new Date(Date.UTC(year, 0, 15, 12)),
new Date(Date.UTC(year, 6, 15, 12))];
var stamp = function (tz, at) {
// Explicit numeric fields rather than dateStyle/timeStyle:
// those are late additions to Intl (Firefox shipped them in
// 91), and an implementation that does not know them ignores
// them and formats the date alone. That would compare
// New York, Chicago and Madrid as equal and tick the step for
// a timezone that is plainly wrong -- the exact failure this
// check exists to catch. These options have been in Intl
// since ECMA-402 v1.
return new Intl.DateTimeFormat('en-US', {
timeZone: tz, year: 'numeric', month: '2-digit',
day: '2-digit', hour: '2-digit', minute: '2-digit',
hour12: false
}).format(at);
};
for (var i = 0; i < instants.length; i++) {
if (stamp(a, instants[i]) !== stamp(b, instants[i])) {
return false;
}
}
return true;
} catch (e) {
// An unparseable zone in the config is worth surfacing, not hiding.
return false;
}
}
(function () {
var tzBtn = card.querySelector('[data-check="timezone"]');
if (!tzBtn) return;
var configured = tzBtn.dataset.tz || '';
if (!configured) return; // nothing saved yet: leave it open
var local = '';
try {
local = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
} catch (e) {
return; // no Intl: leave it to the manual tick
}
if (!local) return;
if (sameZone(configured, local)) {
markDone(tzBtn);
return;
}
// Unticked on its own says "wrong" without saying why; name the zone
// the browser is in so the step is actionable.
var note = tzBtn.querySelector('[data-gs-tz-note]');
if (note) note.textContent = ' — this browser is in ' + local;
}());
// Plugin-derived states from the existing installed-plugins endpoint.
fetch('/api/v3/plugins/installed')
.then(function (r) { return r.json(); })
.then(function (resp) {
var plugins = (resp.data && resp.data.plugins) || [];
if (plugins.length > 0) markDone(card.querySelector('[data-check="installed"]'));
var enabled = plugins.filter(function (p) { return p.enabled; });
if (enabled.length > 0) {
markDone(card.querySelector('[data-check="enabled"]'));
// "Configured" heuristic: the first enabled plugin has at least
// one saved value that differs from its schema defaults.
var pid = enabled[0].id;
Promise.all([
fetch('/api/v3/plugins/config?plugin_id=' + encodeURIComponent(pid)).then(function (r) { return r.json(); }),
fetch('/api/v3/plugins/schema?plugin_id=' + encodeURIComponent(pid)).then(function (r) { return r.json(); })
]).then(function (res) {
// GET /plugins/config returns the config dict directly in .data
var cfg = res[0].data || {};
var props = (res[1].data && res[1].data.schema && res[1].data.schema.properties) || {};
for (var k in cfg) {
if (k === 'enabled' || !(k in props)) continue;
if (props[k] && 'default' in props[k] &&
JSON.stringify(cfg[k]) !== JSON.stringify(props[k]['default'])) {
markDone(card.querySelector('[data-check="configured"]'));
return;
}
}
}).catch(function () {});
}
})
.catch(function () {});
window.dismissGettingStarted = function () {
card.style.display = 'none';
try { localStorage.setItem(KEY, '1'); } catch (e) {}
};
}());
</script>
<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>
@@ -221,7 +449,10 @@
<h3 class="text-md font-medium text-gray-900 mb-4">
<i class="fas fa-desktop"></i> Live Display Preview
</h3>
<div class="bg-gray-900 rounded-lg p-6 border border-gray-700" style="position: relative;">
<!-- overflow-x-auto: on narrow screens a wide preview scrolls at its
true pixel-perfect size instead of being squeezed (fractional
downscaling of pixel art reads as blur) -->
<div class="bg-gray-900 rounded-lg p-6 border border-gray-700 overflow-x-auto" style="position: relative;">
<div id="previewStage" class="preview-stage" style="display:none; position:relative; display:inline-block;">
<div id="previewMeta" style="position:absolute; top:-28px; left:0; color:#ddd; font-size:12px; opacity:0.85;"></div>
<img id="displayImage" style="image-rendering: pixelated; display: block;" alt="LED Matrix Display">
@@ -121,14 +121,21 @@
</label>
{% endif %}
{# Enum dropdown #}
{# Enum dropdown. Option text comes from x-options.labels when the
schema supplies it -- the same convention the checkbox-group
widget already uses -- because humanising the raw value cannot
express every label: "vs" reads as "Vs", and "abbrev" says
nothing about the "Sep 19" it produces. Values without a label
fall back to the humanised form, so existing schemas render
exactly as before. #}
{% elif prop.enum %}
{% set enum_labels = (prop.get('x-options') or prop.get('x_options') or {}).get('labels') or {} %}
<select id="{{ field_id }}"
name="{{ full_key }}"
name="{{ full_key }}"
class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black">
{% for option in prop.enum %}
<option value="{{ option }}" {% if value == option %}selected{% endif %}>
{{ option|replace('_', ' ')|title }}
{{ enum_labels.get(option, option|replace('_', ' ')|title) }}
</option>
{% endfor %}
</select>
@@ -289,7 +296,27 @@
{% set enum_items = items_schema.get('enum') or [] %}
{% set x_options = prop.get('x-options') or {} %}
{% set labels = x_options.get('labels') or {} %}
{# A saved value that is no longer one of the options -- a team
code the league retired, an option dropped from the schema --
has no checkbox to render, so it would sit unseen in the
hidden input below and be posted back on save. The schema
rejects it and the save endpoint returns 400, which blocks
editing any other field on the plugin until the stale entry
is found and removed. Drop them here instead, and say which,
so the value is not lost silently. Only when the widget
actually has options: an empty enum means nothing to check
against, and filtering on it would wipe the field. #}
{% set stale_values = (array_value | reject('in', enum_items) | list) if enum_items else [] %}
{% set array_value = (array_value | select('in', enum_items) | list) if enum_items else array_value %}
{% if stale_values %}
<div class="mt-1 mb-2 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800"
data-stale-options="{{ field_id }}">
No longer offered, and will be removed when you save:
<span class="font-mono">{{ stale_values | join(', ') }}</span>.
</div>
{% endif %}
<div class="mt-1 space-y-2">
{% for option in enum_items %}
{% set is_checked = option in array_value %}
@@ -569,10 +596,14 @@
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
{% elif col_enum %}
{# Labels are opt-in here and the fallback stays the raw
value: table columns hold things like ticker symbols,
which must not be title-cased behind the user's back. #}
{% set col_labels = (col_def.get('x-options') or col_def.get('x_options') or {}).get('labels') or {} %}
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
{% for opt in col_enum %}{% if opt is not none %}
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ opt }}</option>
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ col_labels.get(opt, opt) }}</option>
{% endif %}{% endfor %}
</select>
{% elif col_xwidget == 'date-picker' %}
@@ -804,7 +835,7 @@
<i class="fas fa-info-circle mr-1"></i>
Changes in the file manager save immediately — no need to click Save Configuration.
</p>
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager'] %}
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager', 'google-oauth'] %}
{# Render widget container #}
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
<script>
@@ -893,6 +924,12 @@
<p class="mt-1 text-sm text-gray-600">{{ plugin.description or 'Plugin configuration' }}</p>
</div>
<div class="flex items-center space-x-4">
<button type="button"
onclick="window.previewPluginNow('{{ plugin.id }}')"
class="btn bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 text-sm rounded-md"
title="Run this plugin on the display for 60 seconds and open the live preview">
<i class="fas fa-play mr-1"></i>Preview on display
</button>
<label class="flex items-center cursor-pointer">
<input type="checkbox"
id="plugin-enabled-{{ plugin.id }}"
@@ -1005,13 +1042,53 @@
{# Use property order if defined, otherwise use natural order #}
{# Skip 'enabled' field - it's handled by the header toggle #}
{% set property_order = schema['x-propertyOrder'] if 'x-propertyOrder' in schema else schema.properties.keys()|list %}
{# Flat (non-object) properties flagged "x-advanced": true are
grouped into one collapsed "Advanced Settings" section after
the basic fields. Object-type properties already render as
their own collapsible sections, so the flag is ignored for
them. Schemas without the flag render exactly as before. #}
{% set tiers = namespace(basic=[], advanced=[]) %}
{% for key in property_order %}
{% if key in schema.properties and key != 'enabled' %}
{% set prop = schema.properties[key] %}
{% set value = config[key] if key in config else none %}
{{ render_field(key, prop, value, '', plugin.id) }}
{% set is_object = prop.type is defined and 'object' in prop.type %}
{% if prop.get('x-advanced') and not is_object %}
{% set tiers.advanced = tiers.advanced + [key] %}
{% else %}
{% set tiers.basic = tiers.basic + [key] %}
{% endif %}
{% endif %}
{% endfor %}
{% for key in tiers.basic %}
{% set prop = schema.properties[key] %}
{% set value = config[key] if key in config else none %}
{{ render_field(key, prop, value, '', plugin.id) }}
{% endfor %}
{% if tiers.advanced %}
{% set adv_section_id = (plugin.id ~ '-section-advanced-settings')|replace('.', '-')|replace('_', '-') %}
<div class="nested-section border border-gray-300 rounded-lg mb-4">
<button type="button"
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
aria-controls="{{ adv_section_id }}"
aria-expanded="false"
onclick="toggleSection('{{ adv_section_id }}')">
<div class="flex-1">
<h4 class="font-semibold text-gray-900">
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Settings ({{ tiers.advanced|length }})
</h4>
<p class="text-sm text-gray-600 mt-1">Optional fine-tuning — the defaults work for most setups.</p>
</div>
<i id="{{ adv_section_id }}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
</button>
<div id="{{ adv_section_id }}" class="nested-content bg-gray-50 px-4 py-4 space-y-3 hidden" style="display: none;">
{% for key in tiers.advanced %}
{% set prop = schema.properties[key] %}
{% set value = config[key] if key in config else none %}
{{ render_field(key, prop, value, '', plugin.id) }}
{% endfor %}
</div>
</div>
{% endif %}
{% else %}
{# No schema - render simple form from config #}
{% if config %}
@@ -466,65 +466,6 @@
</div>
</div>
<!-- Plugin Configuration Modal -->
<div id="plugin-config-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
<div class="modal-content p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 id="plugin-config-title" class="text-lg font-semibold">Plugin Configuration</h3>
<div class="flex items-center space-x-2">
<!-- View Toggle -->
<div class="flex items-center bg-gray-100 rounded-lg p-1">
<button id="view-toggle-form" class="view-toggle-btn active px-3 py-1 rounded text-sm font-medium transition-colors" data-view="form">
<i class="fas fa-list mr-1"></i>Form
</button>
<button id="view-toggle-json" class="view-toggle-btn px-3 py-1 rounded text-sm font-medium transition-colors" data-view="json">
<i class="fas fa-code mr-1"></i>JSON
</button>
</div>
<!-- Reset Button -->
<button id="reset-to-defaults-btn" class="px-3 py-1 text-sm bg-yellow-500 hover:bg-yellow-600 text-white rounded transition-colors" title="Reset to defaults">
<i class="fas fa-undo mr-1"></i>Reset
</button>
<button id="close-plugin-config" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- Validation Errors Display -->
<div id="plugin-config-validation-errors" class="hidden mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
<div class="flex items-start">
<i class="fas fa-exclamation-circle text-red-600 mt-0.5 mr-2"></i>
<div class="flex-1">
<p class="text-sm font-medium text-red-800 mb-2">Configuration Validation Errors</p>
<ul id="validation-errors-list" class="text-sm text-red-700 list-disc list-inside space-y-1"></ul>
</div>
</div>
</div>
<!-- Form View -->
<div id="plugin-config-form-view" class="plugin-config-view">
<div id="plugin-config-content">
<!-- Plugin config form will be loaded here -->
</div>
</div>
<!-- JSON Editor View -->
<div id="plugin-config-json-view" class="plugin-config-view hidden">
<div class="mb-2">
<label class="block text-sm font-medium text-gray-700 mb-1">Configuration JSON</label>
<textarea id="plugin-config-json-editor" class="w-full border border-gray-300 rounded-md font-mono text-sm" rows="20"></textarea>
</div>
<div class="flex justify-end space-x-2 pt-2 border-t border-gray-200">
<button type="button" onclick="closePluginConfigModal()" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md">
Cancel
</button>
<button type="button" id="save-json-config-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-save mr-2"></i>Save Configuration
</button>
</div>
</div>
</div>
</div>
</div>
<!-- On-Demand Modal moved to base.html so it's always available -->
<style>
@@ -31,6 +31,24 @@
</div>
<div class="space-y-4">
<!-- Switch branch -->
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Branch</p>
<p class="text-xs text-gray-500 mt-0.5">Choose which branch this pi follows. Switching attaches tracking, so Pull Latest works afterwards.</p>
</div>
<div class="shrink-0 flex items-center gap-2">
<select id="branch-select" class="text-sm border border-gray-300 rounded-md px-2 py-2 bg-white max-w-[14rem]">
<option value="">Loading branches…</option>
</select>
<button id="btn-checkout-branch" onclick="checkoutBranch(false)"
class="inline-flex items-center px-3 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-code-branch mr-2"></i>Switch
</button>
</div>
</div>
<div id="result-checkout-branch" class="hidden"></div>
<!-- Pull latest -->
<div class="flex items-start justify-between gap-4">
<div>
@@ -467,6 +485,14 @@
</div>`;
}
if (d.upstream) {
html += `<p class="text-xs text-gray-500 mt-1"><i class="fas fa-link mr-1"></i>tracking <span class="font-mono">${escHtml(d.upstream)}</span></p>`;
} else if (d.can_pull) {
html += `<p class="text-xs text-blue-700 mt-1"><i class="fas fa-info-circle mr-1"></i>No upstream set; Pull Latest will use <span class="font-mono">origin/${escHtml(d.branch || '')}</span> and set it.</p>`;
} else {
html += `<p class="text-xs text-amber-700 mt-1"><i class="fas fa-triangle-exclamation mr-1"></i>No upstream and no matching branch on origin — Pull Latest cannot run. Switch to a branch that exists on the remote.</p>`;
}
if (d.remote_url) {
html += `<p class="text-xs text-gray-400 mt-1"><i class="fas fa-cloud mr-1"></i>${escHtml(d.remote_url)}</p>`;
}
@@ -479,6 +505,84 @@
});
}
// ── branch picker ─────────────────────────────────────────────────────
// A pi can end up on a branch with no tracking information (checked out by
// name, restored from a backup), where `git pull` refuses to run. Being
// able to see and change the branch from here beats needing SSH.
function loadBranches() {
const sel = document.getElementById('branch-select');
if (!sel) return;
fetch('/api/v3/system/git-branches')
.then(r => r.ok ? r.json() : r.json().then(d => Promise.reject(d.message || `HTTP ${r.status}`)))
.then(d => {
if (d.status === 'error') {
sel.innerHTML = `<option value="">${escHtml(d.message || 'unavailable')}</option>`;
sel.disabled = true;
return;
}
sel.innerHTML = '';
const add = (name, suffix) => {
const o = document.createElement('option');
o.value = name;
o.textContent = name + (suffix || '');
if (name === d.current) o.selected = true;
sel.appendChild(o);
};
(d.local || []).forEach(b => add(b, b === d.current ? ' (current)' : ''));
// Remote-only branches are checked out on demand.
(d.remote_only || []).forEach(b => add(b, ' (remote)'));
if (!sel.options.length) add('', 'no branches found');
})
.catch(err => {
sel.innerHTML = `<option value="">${escHtml(String(err))}</option>`;
sel.disabled = true;
});
}
window.checkoutBranch = function(stash) {
const sel = document.getElementById('branch-select');
const branch = sel && sel.value;
if (!branch) return;
setBusy('btn-checkout-branch', true);
const el = document.getElementById('result-checkout-branch');
if (el) el.classList.add('hidden');
fetch('/api/v3/system/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'checkout_branch', branch: branch, stash: !!stash})
})
.then(r => r.json().catch(() => ({status: 'error', message: `HTTP ${r.status}`})))
.then(d => {
const ok = d.status === 'success';
// Show git's own list of blocking files, then offer the single
// action that clears it. Stashing is never done unasked.
showResult('result-checkout-branch', ok, d.message || '', d.detail || '');
if (!ok && d.can_retry_with_stash && el) {
const retry = document.createElement('div');
retry.className = 'mt-2 flex items-center gap-2';
const label = document.createElement('span');
label.className = 'text-xs text-gray-700';
label.textContent = 'Stash these changes and switch anyway?';
const btn = document.createElement('button');
btn.className = 'inline-flex items-center px-2 py-1 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50';
btn.textContent = 'Stash and switch';
btn.onclick = function() { window.checkoutBranch(true); };
retry.appendChild(label);
retry.appendChild(btn);
el.appendChild(retry);
}
// Both panels describe the checkout, so refresh them together.
loadGitInfo();
loadBranches();
})
.catch(err => showResult('result-checkout-branch', false, String(err)))
.finally(() => setBusy('btn-checkout-branch', false));
};
// ── power supply diagnostics panel ────────────────────────────────────────
// Reuses the same SSE stream (window.statsSource, set up in base.html)
// that already drives the header badge/banner and Overview card, instead
@@ -810,6 +914,7 @@
// Load on first render; HTMX will have already swapped us in by this point.
loadGitInfo();
loadBranches();
// Plugin health: initial load + periodic refresh. Guard against duplicate
// timers if this partial is re-swapped in by HTMX; the handler re-resolves