Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d2b49c4
chore(web): remove dead legacy client-side plugin-config generator (~…
ChuckBuilds Jul 16, 2026
1ae3b7f
feat(web): mobile navigation drawer + responsive CSS gap fixes
ChuckBuilds Jul 16, 2026
d361958
feat(web): x-advanced schema flag groups plugin config fields under a…
ChuckBuilds Jul 16, 2026
af991d4
feat(web): Display Settings basic/advanced split + live total-resolut…
ChuckBuilds Jul 16, 2026
242cd2a
feat(web): plugin install auto-enables + persistent restart nudge
ChuckBuilds Jul 16, 2026
5ebd55d
feat(web): dismissible Getting Started checklist on Overview
ChuckBuilds Jul 16, 2026
d49cb58
feat(display): drag-and-drop plugin rotation order for the primary di…
ChuckBuilds Jul 16, 2026
c548cfe
feat(web): serve the interface at / — /v3 kept as a legacy alias
ChuckBuilds Jul 16, 2026
4552e82
perf(web): stream the preview PNG raw instead of PIL decode + re-encode
ChuckBuilds Jul 16, 2026
8578d83
perf(web): gzip response compression via flask-compress
ChuckBuilds Jul 16, 2026
35a8fbb
perf(web): gate verbose console logging behind the existing pluginDeb…
ChuckBuilds Jul 16, 2026
a10152c
perf(web): vendor CDN assets locally — LAN-speed loads, fully offline…
ChuckBuilds Jul 16, 2026
84c41df
perf(web): extract 3,850 lines of inline JS from base.html to cacheab…
ChuckBuilds Jul 16, 2026
b6a8a88
fix(web): size the live preview from the PNG's real dimensions, not c…
ChuckBuilds Jul 16, 2026
e20fc40
feat(web): fold Display Options into the advanced dropdown; Vegas abo…
ChuckBuilds Jul 16, 2026
42abf5d
feat(web): Getting Started items are manually checkable; card auto-hi…
ChuckBuilds Jul 16, 2026
49a99c1
chore(web): CI cleanup — declare debugLog global, fix entity-unescape…
ChuckBuilds Jul 16, 2026
3ab6a73
fix(web): add the missing nav tab for the Rotation & Durations page
ChuckBuilds Jul 16, 2026
05aee74
fix(web): Rotation & Durations page lists every enabled plugin's screens
ChuckBuilds Jul 16, 2026
69863f7
feat(web): restart-pending banner, unsaved-changes guard, installable…
ChuckBuilds Jul 16, 2026
23dcdc2
test(web): smoke tests + static-analysis audits for the web UI
ChuckBuilds Jul 16, 2026
e1e26e8
feat(web): floating live preview + per-plugin "Preview on display", d…
ChuckBuilds Jul 16, 2026
e00d10c
fix(web): resolve Codacy findings — DOM building over innerHTML, Map …
ChuckBuilds Jul 16, 2026
aec1368
feat(web): update flow installs changed Python dependencies automatic…
ChuckBuilds Jul 16, 2026
8044084
fix(web): address CodeRabbit review — validation, a11y, perf, and pri…
ChuckBuilds Jul 16, 2026
c958630
chore(web): fix remaining real Codacy findings (2 of 6)
ChuckBuilds Jul 16, 2026
64871fd
fix(web): floating preview shows a frame immediately on open + is res…
ChuckBuilds Jul 16, 2026
b54d56a
fix(web): stop duration fields leaking into config root; isolate per-…
ChuckBuilds Jul 16, 2026
f2c1d6f
fix(web): repair garbled Advanced Hardware section description
ChuckBuilds Jul 16, 2026
560513d
fix(web): remove redundant htmx:afterSwap script re-execution (was do…
ChuckBuilds Jul 16, 2026
f4301f2
chore(display): add missing [DisplayController] prefix to the reconci…
ChuckBuilds Jul 16, 2026
30e1837
fix(web): repair dead /v3/logs link on the display hardware-error banner
ChuckBuilds Jul 16, 2026
cf84a76
fix(web): raise display Rows field max from 64 to 128
ChuckBuilds Jul 16, 2026
35bc299
fix(web): remove redundant htmx.org substring check flagged by CodeQL
ChuckBuilds Jul 17, 2026
99ea157
fix(web): restore htmx script re-execution timing that Alpine x-data …
ChuckBuilds Jul 17, 2026
14df879
fix(web): wait for async plugin install to finish before auto-enablin…
ChuckBuilds Jul 17, 2026
6a93b2c
fix(web): resolve remaining valid findings from latest review pass
ChuckBuilds Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/config.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
"axis": "horizontal"
},
"display_durations": {},
"plugin_rotation_order": [],
"use_short_date_format": true,
"vegas_scroll": {
"enabled": false,
Expand Down
34 changes: 34 additions & 0 deletions docs/widget-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,40 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the

The widget will be automatically rendered when the plugin configuration form is loaded.

## Marking Fields as Advanced (`x-advanced`)

Add `"x-advanced": true` to any top-level, non-object property to move it out
of the main form and into a single collapsed **Advanced Settings** section at
the bottom of the plugin's configuration page:

```json
{
"properties": {
"city": {
"type": "string",
"title": "City"
},
"request_timeout": {
"type": "integer",
"default": 10,
"description": "HTTP timeout in seconds",
"x-advanced": true
}
}
}
```

Guidelines:

- Use it for fine-tuning knobs most users never touch (timeouts, retry
behavior, cache TTLs, styling overrides). Anything a first-time user must
set to get the plugin working should stay basic.
- Nothing is hidden permanently — the section expands on click, and the
settings search finds and auto-expands advanced fields like any others.
- The flag is ignored on `object`-type properties (they already render as
their own collapsible sections) and is safely ignored by older cores, so
adding it never breaks compatibility.

## Creating Custom Widgets

### Step 1: Create Widget File
Expand Down
47 changes: 46 additions & 1 deletion src/display_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,10 @@ def load_single_plugin(plugin_id):
logger.debug("%d plugin(s) disabled in config", disabled_count)

logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time)
# Parallel loading appends modes in load-completion order, which
# varies between restarts; apply the user's configured rotation
# order (no-op when not configured).
self._apply_plugin_rotation_order()
logger.info("Total available modes: %d", len(self.available_modes))
logger.info("Available modes: %s", self.available_modes)

Expand Down Expand Up @@ -2843,11 +2847,52 @@ def _reconcile_enabled_plugins(self) -> bool:
except Exception as e:
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)

# Newly enabled plugins were appended at the end; put them in the
# configured rotation slot before resyncing the index.
self._apply_plugin_rotation_order()
self._resync_mode_index_after_change(previous_mode)
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)",
sorted(to_add), sorted(to_remove), len(self.available_modes))
return True

def _apply_plugin_rotation_order(self) -> None:
"""Reorder available_modes to follow display.plugin_rotation_order.

The configured value is a list of plugin ids; their modes rotate in
that order (each plugin's own modes keep their declared order), with
any enabled-but-unlisted plugins appended afterwards in their current
relative order. An empty/missing list leaves available_modes exactly
as built (today's behavior). Mirrors vegas_mode/config.py's
get_ordered_plugins() semantics for the primary rotation.
"""
configured = (self.config.get("display", {}) or {}).get("plugin_rotation_order", []) or []
# Defensive: hand-edited or migrated configs may hold a non-list or
# non-string entries; keep the existing rotation rather than applying
# a garbage order.
if not isinstance(configured, list):
logger.warning("[DisplayController] Ignoring invalid plugin_rotation_order (not a list): %r",
type(configured).__name__)
return
configured = [p for p in configured if isinstance(p, str)]
if not configured or not self.available_modes:
return

ordered_ids = [p for p in configured if p in self.plugin_display_modes]
new_modes: List[str] = []
for plugin_id in ordered_ids:
for mode in self.plugin_display_modes[plugin_id]:
if mode in self.available_modes and mode not in new_modes:
new_modes.append(mode)
# Unlisted plugins' modes (and any mode not attributable to a plugin)
# follow in their existing relative order.
for mode in self.available_modes:
if mode not in new_modes:
new_modes.append(mode)
if new_modes != self.available_modes:
self.available_modes = new_modes
logger.info("[DisplayController] Applied plugin rotation order %s -> modes: %s",
configured, self.available_modes)

def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
"""Clamp rotation state after available_modes changed. Stays on the
previous mode if it survived, otherwise restarts cleanly within range."""
Expand Down
184 changes: 184 additions & 0 deletions test/test_web_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""
Web-UI smoke tests: every page, partial, and critical static asset must render.

These boot the pages blueprint with the same dual registration app.py uses
(un-prefixed primary + /v3 legacy alias) and assert each surface returns 200
with its load-bearing markers present. They exist to catch, in CI, the class
of regression that only shows up when a real request renders a real template:
a broken partial, a missing tab wiring, a renamed element id that JS depends
on, or a static asset that stopped being served.
"""

import sys
from pathlib import Path
from unittest.mock import MagicMock

import pytest
from flask import Flask

PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))


SMOKE_CONFIG = {
"web_display_autostart": True,
"timezone": "America/Chicago",
"location": {"city": "Dallas", "state": "Texas", "country": "US"},
"plugin_system": {
"auto_discover": True,
"auto_load_enabled": True,
"development_mode": False,
"plugins_directory": "plugin-repos",
},
"schedule": {},
"dim_schedule": {"dim_brightness": 30},
"sync": {"role": "standalone", "port": 5765, "follower_position": "left"},
"clock": {"enabled": True},
"ledmatrix-weather": {"enabled": True},
"display": {
"hardware": {
"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1,
"brightness": 95, "hardware_mapping": "adafruit-hat-pwm",
"led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "",
"row_address_type": 0, "scan_mode": 0, "pwm_bits": 9,
"pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130,
"limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False,
"inverse_colors": False, "show_refresh_rate": False,
},
"runtime": {"gpio_slowdown": 3, "rp1_rio": 0},
"double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"},
"use_short_date_format": False,
"dynamic_duration": {"max_duration_seconds": 180},
"vegas_scroll": {
"enabled": False, "scroll_speed": 50, "separator_width": 32,
"target_fps": 125, "buffer_ahead": 2,
"plugin_order": [], "excluded_plugins": [],
},
"display_durations": {"stale_saved_mode": 45},
"plugin_rotation_order": ["ledmatrix-weather", "clock"],
},
}

PLUGIN_MODES = {
"clock": ["clock"],
"ledmatrix-weather": ["weather_current", "weather_daily"],
}


@pytest.fixture
def client():
base = PROJECT_ROOT / "web_interface"
app = Flask(
__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"),
)
app.config["TESTING"] = True

from web_interface.blueprints import pages_v3 as pv

# pages_v3 is a module-level Blueprint singleton shared by the whole test
# process (test_web_settings_ui.py mutates the same attributes) - save
# the originals and restore them on teardown so this fixture can't leak
# its mocks into tests that run afterward.
original_config_manager = getattr(pv.pages_v3, "config_manager", None)
original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None)

mock_cm = MagicMock()
mock_cm.load_config.return_value = SMOKE_CONFIG
mock_cm.get_raw_file_content.return_value = SMOKE_CONFIG
mock_cm.get_config_path.return_value = "config/config.json"
mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
pv.pages_v3.config_manager = mock_cm

mock_pm = MagicMock()
mock_pm.plugins = {}
mock_pm.get_all_plugin_info.return_value = [
{"id": "clock", "name": "Clock"},
{"id": "ledmatrix-weather", "name": "Weather"},
]
mock_pm.get_plugin_display_modes.side_effect = (
lambda pid: PLUGIN_MODES.get(pid, [])
)
pv.pages_v3.plugin_manager = mock_pm

# Same dual registration as web_interface/app.py: un-prefixed primary,
# /v3 kept as a working legacy alias.
app.register_blueprint(pv.pages_v3, url_prefix="")
app.register_blueprint(pv.pages_v3, url_prefix="/v3", name="pages_v3_legacy")
try:
yield app.test_client()
finally:
pv.pages_v3.config_manager = original_config_manager
pv.pages_v3.plugin_manager = original_plugin_manager


# (path, [markers that must appear in the body])
PAGES = [
("/", ["site-nav", "mobileNavOpen", 'rel="manifest"',
"restart-pending-banner", "activeTab = 'durations'"]),
("/partials/overview", ["getting-started-card", "displayImage"]),
("/partials/general", ["timezone"]),
("/partials/display", ["display-section-advanced-hardware",
"display-resolution-value", "vegas_scroll_label"]),
("/partials/durations", ["rotation_plugin_order", "duration__clock",
"duration__weather_current",
"duration__stale_saved_mode"]),
("/partials/schedule", ["schedule"]),
]


@pytest.mark.parametrize("path,markers", PAGES, ids=[p for p, _ in PAGES])
def test_page_renders_with_markers(client, path, markers):
resp = client.get(path)
assert resp.status_code == 200, f"{path} -> {resp.status_code}"
body = resp.get_data(as_text=True)
for marker in markers:
assert marker in body, f"{path}: missing marker {marker!r}"


@pytest.mark.parametrize("path", [p for p, _ in PAGES if p != "/"])
def test_legacy_v3_alias_serves_the_same_partials(client, path):
assert client.get("/v3" + path).status_code == 200


STATIC_ASSETS = [
"/static/v3/app.css",
"/static/v3/app.js",
"/static/v3/manifest.json",
"/static/v3/icons/icon-192.png",
"/static/v3/js/app-shell.js",
"/static/v3/js/app-early.js",
"/static/v3/js/htmx-config.js",
"/static/v3/js/widgets/plugin-order-list.js",
"/static/v3/js/widgets/notification.js",
"/static/v3/vendor/fontawesome/css/all.min.css",
"/static/v3/vendor/codemirror/codemirror.min.js",
]


@pytest.mark.parametrize("asset", STATIC_ASSETS)
def test_static_asset_served(client, asset):
resp = client.get(asset)
assert resp.status_code == 200, f"{asset} -> {resp.status_code}"
assert len(resp.data) > 0


def test_durations_page_groups_by_plugin(client):
"""One duration input per display mode of each enabled plugin, plus the
leftover group for saved keys no enabled plugin owns."""
body = client.get("/partials/durations").get_data(as_text=True)
assert body.count("duration__") >= 2 * len(
[m for modes in PLUGIN_MODES.values() for m in modes]
) # each mode: id= and name=
assert "Other saved entries" in body


def test_display_advanced_section_contains_tuning_fields(client):
body = client.get("/partials/display").get_data(as_text=True)
adv = body.find('id="display-section-advanced-hardware"')
adv_close = body.find("/#display-section-advanced-hardware")
assert 0 < adv < adv_close
for field in ["multiplexing", "pwm_bits", "inverse_colors"]:
pos = body.find(f'name="{field}"')
assert adv < pos < adv_close, f"{field} not inside the advanced section"
85 changes: 85 additions & 0 deletions test/test_web_static_audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""
Static-analysis audits for the web UI, as tests so CI enforces them.

1. Breakpoint utility audit: app.css hand-maintains a Tailwind-style utility
subset, so a template can reference a responsive class (e.g. sm:block)
that no CSS rule defines — it silently no-ops. This once left the header
search box and system stats invisible at every screen width. The audit
diffs classes used in templates against classes defined in app.css.

2. Asset reference audit: every url_for('static', filename=...) in the
templates must point to a file that exists, so a renamed/moved asset
can't ship as a broken <script>/<link>/<img>.

3. debugLog globals audit: any static JS file calling debugLog() (a global
defined in base.html) must declare it in a /* global */ header so linting
stays clean and the dependency is explicit.
"""

import re
from pathlib import Path

PROJECT_ROOT = Path(__file__).parent.parent
WEB = PROJECT_ROOT / "web_interface"
TEMPLATES = WEB / "templates"
STATIC = WEB / "static"
APP_CSS = STATIC / "v3" / "app.css"

BP_PREFIXES = ("sm", "md", "lg", "xl", "2xl")


def _template_files():
return sorted(TEMPLATES.rglob("*.html"))


def test_every_used_breakpoint_class_is_defined():
used = set()
class_attr = re.compile(r'class="([^"]*)"')
bp_class = re.compile(r"\b(%s):[A-Za-z0-9_.-]+" % "|".join(BP_PREFIXES))
for path in _template_files():
for attr in class_attr.findall(path.read_text()):
for m in bp_class.finditer(attr):
used.add(m.group(0))

css = APP_CSS.read_text()
defined = {
m.group(0).lstrip(".").replace("\\:", ":")
for m in re.finditer(
r"\.(%s)\\:[A-Za-z0-9_-]+" % "|".join(BP_PREFIXES), css
)
}

missing = sorted(used - defined)
assert not missing, (
"Responsive utility classes referenced in templates but never defined "
f"in app.css (they silently no-op): {missing}"
)


def test_every_static_url_for_points_to_a_real_file():
ref = re.compile(
r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]"
)
missing = []
for path in _template_files():
for filename in ref.findall(path.read_text()):
if not (STATIC / filename).is_file():
missing.append(f"{path.relative_to(PROJECT_ROOT)}: {filename}")
assert not missing, f"Templates reference missing static assets: {missing}"


def test_js_files_calling_debuglog_declare_the_global():
undeclared = []
for path in sorted((STATIC / "v3").rglob("*.js")):
if "vendor" in path.parts:
continue
text = path.read_text()
# Calls debugLog( but neither defines it nor declares the global
calls = re.search(r"(?<![.\w])debugLog\(", text)
defines = "window.debugLog" in text
declares = re.search(r"/\*\s*global[^*]*\bdebugLog\b", text)
if calls and not defines and not declares:
undeclared.append(str(path.relative_to(PROJECT_ROOT)))
assert not undeclared, (
f"JS files call debugLog() without a /* global debugLog */ header: {undeclared}"
)
Loading
Loading