Compare commits

..
Author SHA1 Message Date
ChuckBuilds deff6bc5f0 Merge remote-tracking branch 'origin/main' into check-412-conflict
# Conflicts:
#	src/image_utils.py
2026-07-14 17:18:08 -04:00
ChuckBuildsandClaude Sonnet 5 b0a404ef26 fix: remove content accidentally bundled into the dead-code-removal commit
The previous commit's git add/commit swept in a lot of unrelated,
unreviewed work alongside the intended dead-code deletions: a new Plugin
Composer web UI, new security-audit/CI tooling, a new march-madness
plugin, and 23 local-development-only symlinks under plugin-repos/ (per
scripts/setup_plugin_repos.py's own docstring, these are meant to be
generated locally, never committed -- .gitignore has no entry for them,
which is how they slipped in).

Removed here, split into their own PRs instead (except plugin-repos/*
symlinks and march-madness/ncaa_logos, which are dropped rather than
carried forward -- see PR discussion):
- web_interface/blueprints/composer.py + composer-app.js +
  composer-canvas.js + composer.html + manager.py.j2
- scripts/prove_security.py, audit_plugins.py, generate_report.py
- .github/workflows/security-audit.yml, .github/workflows/tests.yml,
  bandit.yaml
- All plugin-repos/* symlinks (local dev artifacts, not meant to be
  committed at all)
- plugin-repos/march-madness/* and the 4 new assets/sports/ncaa_logos/*
  PNGs it needed (left out of every split PR pending a decision on
  whether march-madness belongs in the core repo or the plugin monorepo)

This PR now contains only what its title describes: the dead-code
removal from the previous commit, untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-14 16:40:34 -04:00
ChuckandClaude Fable 5 73a5304194 chore: remove dead modules and unused dependencies (~1,180 LOC)
Deletions, each re-verified with a fresh repo-wide grep (core, web,
scripts, docs, plugin monorepo) immediately before removal:

Modules with zero live importers:
- src/background_cache_mixin.py + src/generic_cache_mixin.py (134+150
  LOC — referenced only by each other)
- src/font_test_manager.py (134 LOC)
- src/image_utils.py (22 LOC, self-documented deprecated)
- src/layout_manager.py (408 LOC — only its own test imported it) +
  test/test_layout_manager.py
- src/common/basketball_plugin_example.py (328 LOC sample)

requirements.txt entries with zero importers in core (pre-plugin-era
manager deps): icalevents, geopy, timezonefinder, unidecode. Plus the
google-auth trio (google-auth-oauthlib, google-auth-httplib2,
google-api-python-client): their only importer is the calendar PLUGIN,
which declares all three in its own requirements.txt (verified in the
monorepo and on an installed copy) — the plugin dependency installer
owns them. Existing venvs are unaffected (removal doesn't uninstall);
fresh installs get them when calendar is installed.

Two stale references cleaned (a comment in test_pillow_compat.py, a
directory listing in HOW_TO_RUN_TESTS.md). Full suite green except the
two documented pre-existing failures (circuit_breaker mock drift, fixed
in #400; clock-simple 64x32 overflow, pre-dates this series); all core
entry modules verified importing cleanly under the emulator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
2026-07-13 07:54:39 -04:00
56 changed files with 6663 additions and 7222 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

-29
View File
@@ -1,29 +0,0 @@
# bandit.yaml — LEDMatrix bandit configuration
# https://bandit.readthedocs.io/en/latest/config.html
#
# Skips are justified by the specific codebase context documented below.
# Do not remove skips without updating the justification comment.
skips:
# B104: Binding to all interfaces (0.0.0.0)
# Intentional — the Flask server binds 0.0.0.0 for LAN access on a Raspberry Pi.
# This is not internet-facing and is documented in web_interface/app.py.
- B104
# B603: subprocess call without shell=True
# All subprocess.run() calls in this codebase use list arguments (confirmed by
# grep — zero uses of shell=True in src/ or web_interface/). List args prevent
# shell injection. See src/common/permission_utils.py for the primary usage.
- B603
# B607: Starting a process with a partial executable path
# The subprocess calls invoke system utilities (systemctl, sudo, git) by name.
# These are fixed-list invocations, not user-controlled, and rely on PATH.
- B607
exclude_dirs:
- tests
- test
- venv
- .venv
- rpi-rgb-led-matrix-master
-1
View File
@@ -121,7 +121,6 @@
"axis": "horizontal" "axis": "horizontal"
}, },
"display_durations": {}, "display_durations": {},
"plugin_rotation_order": [],
"use_short_date_format": true, "use_short_date_format": true,
"vegas_scroll": { "vegas_scroll": {
"enabled": false, "enabled": false,
-34
View File
@@ -206,40 +206,6 @@ 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. 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 ## Creating Custom Widgets
### Step 1: Create Widget File ### Step 1: Create Widget File
-344
View File
@@ -1,344 +0,0 @@
#!/usr/bin/env python3
"""
LEDMatrix Plugin Security Auditor
Performs AST-based security analysis of all Python files in plugin directories.
Designed to run in CI — exits non-zero on CRITICAL findings only.
Usage:
python scripts/audit_plugins.py
python scripts/audit_plugins.py --verbose
python scripts/audit_plugins.py --plugin hello-world
python scripts/audit_plugins.py --output results.json
"""
import ast
import argparse
import json
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from datetime import datetime, timezone
PROJECT_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_BASE_DIRS = [
PROJECT_ROOT / "plugins",
PROJECT_ROOT / "plugin-repos",
]
# ─────────────────────────────────────────────────────────────────────────────
# Finding dataclass
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class Finding:
plugin_id: str
file: str
line: int
severity: str # CRITICAL | WARNING | INFO
rule: str
message: str
def to_dict(self) -> dict:
return asdict(self)
# ─────────────────────────────────────────────────────────────────────────────
# AST visitor
# ─────────────────────────────────────────────────────────────────────────────
class _PluginVisitor(ast.NodeVisitor):
"""Collect security findings from a single plugin Python file."""
def __init__(self, filepath: Path, plugin_id: str):
self.filepath = filepath
self.plugin_id = plugin_id
self.findings: list[Finding] = []
# Local name -> real dotted path, so aliased imports and from-imports
# of dangerous APIs (import subprocess as sp; from builtins import
# eval as e) are still recognized in visit_Call below.
self._aliases: dict[str, str] = {}
def _add(self, node: ast.AST, severity: str, rule: str, message: str) -> None:
self.findings.append(Finding(
plugin_id=self.plugin_id,
file=str(self.filepath.relative_to(PROJECT_ROOT)),
line=getattr(node, "lineno", 0),
severity=severity,
rule=rule,
message=message,
))
def _resolve(self, local_name: str) -> str:
"""Resolve a local name through recorded import aliases to its real
dotted path (e.g. "sp" -> "subprocess"); unresolved names pass through
unchanged."""
return self._aliases.get(local_name, local_name)
def _resolve_call_target(self, func: ast.expr) -> str | None:
"""Resolve a Call's func node to a fully-qualified dotted target,
covering a direct name (bare builtin, aliased import, or
from-import: from builtins import eval as e; from subprocess
import run; from os import system as s) and module-attribute
access (subprocess.run, sp.run, os.system, o.system) uniformly.
Returns None for call shapes this doesn't attempt to resolve."""
if isinstance(func, ast.Name):
return self._resolve(func.id)
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
base = self._resolve(func.value.id)
return f"{base}.{func.attr}"
return None
def visit_Call(self, node: ast.Call) -> None:
target = self._resolve_call_target(node.func)
if target is None:
self.generic_visit(node)
return
leaf = target.rsplit(".", 1)[-1]
# eval() / exec() / compile() — arbitrary code execution, whether a
# bare call, an aliased import, or a from-import
# (from builtins import eval as e; e(...))
if leaf == "eval":
self._add(node, "CRITICAL", "PLUGIN-001",
"eval() call — arbitrary code execution risk")
elif leaf == "exec":
self._add(node, "CRITICAL", "PLUGIN-002",
"exec() call — arbitrary code execution risk")
elif leaf == "compile":
self._add(node, "WARNING", "PLUGIN-003",
"compile() call — dynamic code compilation")
# subprocess.*(shell=True), whether subprocess.run(...), sp.run(...),
# or a from-import (from subprocess import run; run(..., shell=True))
if target in {
"subprocess.run", "subprocess.call", "subprocess.Popen",
"subprocess.check_call", "subprocess.check_output",
}:
for kw in node.keywords:
if (kw.arg == "shell" and
isinstance(kw.value, ast.Constant) and
kw.value.value is True):
self._add(node, "WARNING", "PLUGIN-004",
f"subprocess.{leaf}(shell=True) — "
f"shell injection risk if args include user input")
# os.system(), whether os.system(...), o.system(...), or a
# from-import (from os import system as s; s(...))
if target == "os.system":
self._add(node, "WARNING", "PLUGIN-005",
"os.system() call — prefer subprocess with list args")
self.generic_visit(node)
def visit_Import(self, node: ast.Import) -> None:
for alias in node.names:
if alias.asname:
local, real = alias.asname, alias.name
else:
# `import os.path` binds the top-level name `os`, not `os.path`
local = real = alias.name.split(".")[0]
self._aliases[local] = real
self._check_import(node, alias.name)
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
if node.module:
for alias in node.names:
local = alias.asname or alias.name
self._aliases[local] = f"{node.module}.{alias.name}"
self._check_import(node, node.module)
self.generic_visit(node)
def _check_import(self, node: ast.AST, module_name: str) -> None:
dangerous = {
"ctypes": ("WARNING", "PLUGIN-010", "ctypes import — native code execution"),
"cffi": ("WARNING", "PLUGIN-011", "cffi import — native code execution"),
"pickle": ("WARNING", "PLUGIN-012",
"pickle import — deserialization can execute arbitrary code"),
"marshal": ("WARNING", "PLUGIN-013",
"marshal import — deserialization risk"),
}
for mod, (severity, rule, msg) in dangerous.items():
if module_name == mod or module_name.startswith(mod + "."):
self._add(node, severity, rule, msg)
# ─────────────────────────────────────────────────────────────────────────────
# Per-plugin audit
# ─────────────────────────────────────────────────────────────────────────────
def audit_plugin(plugin_dir: Path) -> list[Finding]:
"""Audit a single plugin directory. Returns all findings."""
findings: list[Finding] = []
plugin_id = plugin_dir.name
# Check for required files
for required_file, rule, msg in [
("manifest.json", "PLUGIN-020",
"manifest.json missing — plugin may be incomplete"),
("config_schema.json", "PLUGIN-021",
"config_schema.json missing — no input validation schema declared"),
]:
if not (plugin_dir / required_file).exists():
findings.append(Finding(
plugin_id=plugin_id,
file=str((plugin_dir / required_file).relative_to(PROJECT_ROOT)),
line=0,
severity="WARNING",
rule=rule,
message=msg,
))
# AST analysis of all Python files
for py_file in sorted(plugin_dir.rglob("*.py")):
try:
source = py_file.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(py_file))
visitor = _PluginVisitor(py_file, plugin_id)
visitor.visit(tree)
findings.extend(visitor.findings)
except SyntaxError as exc:
# A file the visitor can't even parse is a file we can't verify
# is safe -- this must block the audit, not just warn.
findings.append(Finding(
plugin_id=plugin_id,
file=str(py_file.relative_to(PROJECT_ROOT)),
line=getattr(exc, "lineno", 0) or 0,
severity="CRITICAL",
rule="PLUGIN-030",
message=f"Python syntax error — cannot be parsed: {exc}",
))
except OSError as exc:
# Same reasoning as SyntaxError: an unreadable file was never
# actually scanned, so it must block rather than pass silently.
findings.append(Finding(
plugin_id=plugin_id,
file=str(py_file.relative_to(PROJECT_ROOT)),
line=0,
severity="CRITICAL",
rule="PLUGIN-031",
message=f"Could not read file: {exc}",
))
return findings
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="LEDMatrix plugin security auditor",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--plugin", "-p", default=None,
help="Audit a specific plugin ID only")
parser.add_argument("--output", "-o", default=None,
help="Write JSON results to this file")
parser.add_argument("--verbose", "-v", action="store_true",
help="Show all findings, not just summary")
args = parser.parse_args()
print("=" * 60)
print("LEDMatrix Plugin Security Audit")
print(f"Project root: {PROJECT_ROOT}")
print("=" * 60)
all_findings: list[Finding] = []
plugins_scanned = 0
plugin_found = args.plugin is None
for base_dir in PLUGIN_BASE_DIRS:
if not base_dir.exists():
if args.verbose:
print(f" ⏭️ Skipping {base_dir.name}/ (directory not found)")
continue
base_label = base_dir.relative_to(PROJECT_ROOT)
print(f"\n Scanning {base_label}/")
for plugin_dir in sorted(base_dir.iterdir()):
if not plugin_dir.is_dir():
continue
if plugin_dir.name.startswith((".", "_")):
continue
if args.plugin and plugin_dir.name != args.plugin:
continue
if args.plugin:
plugin_found = True
findings = audit_plugin(plugin_dir)
all_findings.extend(findings)
plugins_scanned += 1
critical = [f for f in findings if f.severity == "CRITICAL"]
warnings = [f for f in findings if f.severity == "WARNING"]
if critical:
icon, label = "🚨", "CRITICAL"
elif warnings:
icon, label = "⚠️ ", "WARN "
else:
icon, label = "", "PASS "
print(f" {icon} [{label}] {plugin_dir.name}"
f"{len(critical)} critical, {len(warnings)} warnings")
if args.verbose:
for f in findings:
severity_icon = {"CRITICAL": "🚨", "WARNING": "⚠️ ", "INFO": ""}.get(
f.severity, " "
)
print(f" {severity_icon} {f.rule} {f.file}:{f.line}{f.message}")
if args.plugin and not plugin_found:
print(f"\n 🚨 Plugin '{args.plugin}' not found in any of "
f"{[str(d.relative_to(PROJECT_ROOT)) for d in PLUGIN_BASE_DIRS]}"
f"nothing was audited")
return 1
# Summary
critical_findings = [f for f in all_findings if f.severity == "CRITICAL"]
warning_findings = [f for f in all_findings if f.severity == "WARNING"]
print(f"\n{'=' * 60}")
print(f" Plugins scanned : {plugins_scanned}")
print(f" CRITICAL : {len(critical_findings)}")
print(f" WARNING : {len(warning_findings)}")
if critical_findings:
print("\n 🚨 CRITICAL findings:")
for f in critical_findings:
print(f" {f.plugin_id} | {Path(f.file).name}:{f.line} | {f.message}")
# Write JSON output
if args.output:
output_data = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"plugins_scanned": plugins_scanned,
"summary": {
"critical": len(critical_findings),
"warnings": len(warning_findings),
},
"findings": [f.to_dict() for f in all_findings],
}
Path(args.output).write_text(
json.dumps(output_data, indent=2), encoding="utf-8"
)
print(f"\n Results written to: {args.output}")
if critical_findings:
print("\n 🚨 Blocking — CRITICAL issues must be resolved")
return 1
print("\n ✅ No critical issues found")
return 0
if __name__ == "__main__":
sys.exit(main())
-356
View File
@@ -1,356 +0,0 @@
#!/usr/bin/env python3
"""
Security Report Generator
Aggregates JSON output from all CI security audit jobs into a single
Markdown report suitable for PR comments and artifact storage.
Expected artifact layout (from actions/download-artifact@v4):
<artifact-dir>/
sast-results/
bandit-results.json
semgrep-results.json
dependency-audit-results/
pip-audit-results.json
safety-results.json
secrets-scan-results/
gitleaks-results.json
security-proofs-results/
security-proofs-results.json
plugin-audit-results/
plugin-audit-results.json
Usage:
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md --verbose
"""
import argparse
import json
import sys
from pathlib import Path
from datetime import datetime, timezone
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# Gitleaks matches exactly equal to one of these (not a substring match -- a
# real secret that merely contains one of these words as part of its actual
# value must still be reported) are known template placeholders.
_GITLEAKS_SUPPRESS_EXACT_VALUES = {
"YOUR_YOUTUBE_API_KEY",
"YOUR_YOUTUBE_CHANNEL_ID",
"YOUR_GITHUB_PERSONAL_ACCESS_TOKEN",
}
# Findings in these files are suppressed regardless of value -- they are
# template/example files that are expected to only ever contain placeholders.
_GITLEAKS_SUPPRESS_PATHS = [
"config_secrets.template.json",
"config.template.json",
]
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _load(path: Path) -> tuple[dict | list | None, str | None]:
"""Load a JSON artifact file.
Returns (data, error): error is None on success (data is whatever was
parsed, which may legitimately be an empty list/dict for a clean scan);
otherwise error is a human-readable reason the artifact is unavailable,
distinguishing "missing/malformed artifact" from "valid empty result" so
callers don't silently treat a broken CI job as a clean pass.
"""
if not path.exists():
return None, f"artifact not found: {path}"
try:
return json.loads(path.read_text(encoding="utf-8")), None
except (json.JSONDecodeError, OSError) as exc:
return None, f"could not read/parse {path}: {exc}"
def _md_sanitize_cell(value: object) -> str:
"""Escape/normalize a value so scanner-controlled content (a matched
secret, a bandit issue_text, a file path) can't alter the Markdown
table's structure: pipes would add bogus columns, newlines would break
out of the row (or forge a fake header/separator line)."""
text = str(value)
text = text.replace("\\", "\\\\").replace("|", "\\|")
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
return text
def _md_table_row(*cells: str) -> str:
return "| " + " | ".join(_md_sanitize_cell(c) for c in cells) + " |"
# ─────────────────────────────────────────────────────────────────────────────
# Per-tool summarizers
# Returns: (markdown_lines: list[str], critical_count: int, available: bool)
# `available=False` means the artifact was missing or malformed -- distinct
# from a valid scan that simply found nothing -- so the caller can report
# INCOMPLETE instead of silently counting it as a clean pass.
# ─────────────────────────────────────────────────────────────────────────────
def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "sast-results" / "bandit-results.json")
if error:
return [f"_bandit results unavailable: {error}_"], 0, False
results = data.get("results", [])
high = [r for r in results if r.get("issue_severity") == "HIGH"]
medium = [r for r in results if r.get("issue_severity") == "MEDIUM"]
low = [r for r in results if r.get("issue_severity") == "LOW"]
lines = [
f"**Bandit**: {len(high)} HIGH · {len(medium)} MEDIUM · {len(low)} LOW"
]
if high:
lines += [
"",
"| Severity | File | Line | Issue |",
"| --- | --- | --- | --- |",
]
for r in high[:10]:
fname = Path(r.get("filename", "")).name
lines.append(_md_table_row(
"HIGH", f"`{fname}`",
str(r.get("line_number", "?")),
r.get("issue_text", "")
))
if len(high) > 10:
lines.append(f"_… and {len(high) - 10} more HIGH findings_")
return lines, len(high), True
def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "dependency-audit-results" / "pip-audit-results.json")
if error:
return [f"_pip-audit results unavailable: {error}_"], 0, False
# pip-audit JSON format: {"dependencies": [{"name": ..., "vulns": [...]}]}
vulns: list[dict] = []
for dep in data.get("dependencies", []):
for v in dep.get("vulns", []):
vulns.append({"package": dep.get("name", "?"), **v})
lines = [f"**pip-audit**: {len(vulns)} vulnerabilities found"]
if vulns:
lines += ["", "| Package | ID | Fix |", "| --- | --- | --- |"]
for v in vulns[:10]:
fix = v.get("fix_versions", ["none"])
fix_str = ", ".join(fix) if fix else "none"
lines.append(_md_table_row(
v.get("package", "?"),
v.get("id", "?"),
fix_str,
))
# Treat known vulnerabilities as warnings, not critical (they may be unavoidable)
return lines, 0, True
def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "secrets-scan-results" / "gitleaks-results.json")
if error:
return [f"_gitleaks results unavailable: {error}_"], 0, False
if not isinstance(data, list):
data = []
real_findings = []
suppressed = 0
for finding in data:
secret_val = str(finding.get("Secret", "") or finding.get("Match", ""))
file_name = Path(finding.get("File", "")).name
if (secret_val in _GITLEAKS_SUPPRESS_EXACT_VALUES
or file_name in _GITLEAKS_SUPPRESS_PATHS):
suppressed += 1
else:
real_findings.append(finding)
lines = [
f"**Gitleaks**: {len(real_findings)} finding(s) "
f"({suppressed} suppressed as template placeholders)"
]
if real_findings:
lines += ["", "| Rule | File | Line | Description |", "| --- | --- | --- | --- |"]
for f in real_findings[:10]:
fname = Path(f.get("File", "")).name
lines.append(_md_table_row(
f.get("RuleID", "?"),
f"`{fname}`",
str(f.get("StartLine", "?")),
f.get("Description", ""),
))
critical = len(real_findings) # any real secret is critical
return lines, critical, True
def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "security-proofs-results" / "security-proofs-results.json")
if error:
return [f"_security proofs results unavailable: {error}_"], 0, False
if not isinstance(data, list):
data = []
critical = [r for r in data if r.get("severity") == "CRITICAL"]
warnings = [r for r in data if r.get("severity") == "WARNING"]
passed = [r for r in data if r.get("severity") == "PASS"]
skipped = [r for r in data if r.get("severity") == "SKIP"]
lines = [
f"**Security Proofs**: "
f"{len(passed)} PASS · {len(warnings)} WARN · "
f"{len(critical)} CRITICAL · {len(skipped)} SKIP",
"",
]
_icon = {"PASS": "", "INFO": "", "WARNING": "⚠️", # nosec B105 - severity labels, not credentials
"CRITICAL": "🚨", "SKIP": "⏭️"}
for r in data:
icon = _icon.get(r.get("severity", ""), "")
lines.append(
f"- {icon} **{r.get('test_id', '?')}**: {r.get('message', '')}"
)
if r.get("details") and r.get("severity") in ("CRITICAL", "WARNING"):
lines.append(f" - _{r['details']}_")
return lines, len(critical), True
def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "plugin-audit-results" / "plugin-audit-results.json")
if error:
return [f"_plugin audit results unavailable: {error}_"], 0, False
summary = data.get("summary", {})
findings = data.get("findings", [])
critical_findings = [f for f in findings if f.get("severity") == "CRITICAL"]
warning_findings = [f for f in findings if f.get("severity") == "WARNING"]
lines = [
f"**Plugin Audit**: {data.get('plugins_scanned', '?')} plugins scanned — "
f"{summary.get('critical', 0)} CRITICAL · {summary.get('warnings', 0)} WARNINGS"
]
if critical_findings:
lines += ["", "| Plugin | File | Line | Rule | Message |",
"| --- | --- | --- | --- | --- |"]
for f in critical_findings[:10]:
fname = Path(f.get("file", "")).name
lines.append(_md_table_row(
f.get("plugin_id", "?"),
f"`{fname}`",
str(f.get("line", "?")),
f.get("rule", "?"),
f.get("message", ""),
))
if warning_findings and not critical_findings:
lines.append(f"\n_{len(warning_findings)} warning(s) found — see artifact for details_")
return lines, summary.get("critical", 0), True
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate consolidated security audit report",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--artifact-dir", required=True,
help="Directory containing downloaded CI artifacts")
parser.add_argument("--output", "-o", required=True,
help="Output Markdown file path")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
artifact_dir = Path(args.artifact_dir)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
bandit_lines, bandit_crit, bandit_ok = _summarize_bandit(artifact_dir)
pip_audit_lines, pip_audit_crit, pip_audit_ok = _summarize_pip_audit(artifact_dir)
gitleaks_lines, gitleaks_crit, gitleaks_ok = _summarize_gitleaks(artifact_dir)
proofs_lines, proofs_crit, proofs_ok = _summarize_security_proofs(artifact_dir)
plugins_lines, plugins_crit, plugins_ok = _summarize_plugin_audit(artifact_dir)
unavailable_tools = [
name for name, ok in [
("bandit", bandit_ok), ("pip-audit", pip_audit_ok),
("gitleaks", gitleaks_ok), ("security-proofs", proofs_ok),
("plugin-audit", plugins_ok),
] if not ok
]
total_critical = bandit_crit + pip_audit_crit + gitleaks_crit + proofs_crit + plugins_crit
if unavailable_tools:
# A missing/malformed artifact means that tool's checks never
# actually ran -- this must not be reported as a clean PASS just
# because the *artifacts that did load* found nothing.
overall = "INCOMPLETE ⚠️"
elif total_critical > 0:
overall = "ACTION REQUIRED 🚨"
else:
overall = "PASSED ✅"
def section(title: str, lines: list[str]) -> str:
return f"### {title}\n\n" + "\n".join(lines) + "\n"
incomplete_note = (
f"\n_⚠️ Incomplete: results unavailable for {', '.join(unavailable_tools)} "
f"— see the corresponding section(s) below for details_\n"
if unavailable_tools else ""
)
report = f"""## 🔒 Security Audit — {overall}
_Generated: {timestamp}_
{incomplete_note}
| Critical | High/Warn | Overall |
| :---: | :---: | :---: |
| {'🚨 ' + str(total_critical) if total_critical else '✅ 0'} | ⚠️ see below | {overall} |
---
{section('SAST — Bandit', bandit_lines)}
{section('Dependencies — pip-audit', pip_audit_lines)}
{section('Secrets — Gitleaks', gitleaks_lines)}
{section('LEDMatrix Security Proofs', proofs_lines)}
{section('Plugin Security Audit', plugins_lines)}
---
_Total critical findings: **{total_critical}**_
"""
output_path = Path(args.output)
output_path.write_text(report, encoding="utf-8")
if args.verbose:
print(f" Report written to: {output_path}")
print(f" Status: {overall}")
print(f" Critical findings: {total_critical}")
print(f" bandit={bandit_crit} pip-audit={pip_audit_crit} "
f"gitleaks={gitleaks_crit} proofs={proofs_crit} plugins={plugins_crit}")
if unavailable_tools:
print(f" Unavailable: {', '.join(unavailable_tools)}")
if unavailable_tools:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
-593
View File
@@ -1,593 +0,0 @@
#!/usr/bin/env python3
"""
LEDMatrix Security Proof Tests
Automated proofs that run in CI to verify security properties hold on every
commit. Inspired by the Huntarr security review approach of using standard
tooling to confirm specific vulnerability classes are absent.
Usage:
python scripts/prove_security.py
python scripts/prove_security.py --verbose
python scripts/prove_security.py --output results.json
Exit code: 1 only if CRITICAL findings are detected. Warnings are reported
but do not block CI.
"""
import ast
import argparse
import hashlib
import json
import re
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# ─────────────────────────────────────────────────────────────────────────────
# Result dataclass
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class TestResult:
test_id: str
severity: str # PASS | INFO | WARNING | CRITICAL | SKIP
message: str
details: str = ""
def to_dict(self) -> dict:
return asdict(self)
@property
def icon(self) -> str:
return {
"PASS": "", # nosec B105 - severity label, not a credential
"INFO": "",
"WARNING": "⚠️ ",
"CRITICAL": "🚨",
"SKIP": "⏭️ ",
}.get(self.severity, "")
# ─────────────────────────────────────────────────────────────────────────────
# T1: Plugin Loading / Zip Slip
# ─────────────────────────────────────────────────────────────────────────────
def test_t1a_zip_slip_protection() -> TestResult:
"""
Verify that zip-slip protection actually guards zip extraction in
store_manager.py.
A whole-file substring check for "is_relative_to"/"Zip-slip detected"
would pass even if the guard existed somewhere unrelated, or covered
only one of several extract()/extractall() call sites. Instead, this
walks the AST: for every extract()/extractall() call, it confirms an
is_relative_to() check (and the "Zip-slip detected" log) appears
earlier in that same enclosing function -- validate-then-bulk-extract
(validate every member, then call extractall() only after all passed)
counts as protecting the call, since it covers the same member list.
"""
store_manager = PROJECT_ROOT / "src" / "plugin_system" / "store_manager.py"
if not store_manager.exists():
return TestResult("T1a", "CRITICAL",
"store_manager.py not found",
f"Expected at {store_manager}")
content = store_manager.read_text(encoding="utf-8")
try:
tree = ast.parse(content, filename=str(store_manager))
except SyntaxError as exc:
return TestResult("T1a", "CRITICAL",
"store_manager.py could not be parsed",
str(exc))
extraction_sites = 0
unprotected: list[str] = []
for func in ast.walk(tree):
if not isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
extract_calls = [
node for node in ast.walk(func)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr in ("extract", "extractall")
]
if not extract_calls:
continue
extraction_sites += len(extract_calls)
guard_lines = [
n.lineno for n in ast.walk(func)
if isinstance(n, ast.Attribute) and n.attr == "is_relative_to"
]
has_zip_slip_log = any(
isinstance(n, ast.Constant) and isinstance(n.value, str)
and "Zip-slip detected" in n.value
for n in ast.walk(func)
)
for call in extract_calls:
guarded = has_zip_slip_log and any(g < call.lineno for g in guard_lines)
if not guarded:
unprotected.append(
f"{func.name}() line {call.lineno}: {call.func.attr}() call not "
f"clearly preceded by an is_relative_to() guard + Zip-slip log "
f"in the same function"
)
if extraction_sites == 0:
return TestResult("T1a", "WARNING",
"No zipfile extract()/extractall() calls found in store_manager.py",
"Verify plugin installation no longer extracts zip archives, "
"or that this check still targets the right file")
if unprotected:
return TestResult("T1a", "CRITICAL",
f"{len(unprotected)} of {extraction_sites} zip extraction "
f"call(s) not clearly guarded",
"; ".join(unprotected))
return TestResult("T1a", "PASS",
"Zip-slip protection verified",
f"All {extraction_sites} extract()/extractall() call(s) in "
f"store_manager.py are preceded by an is_relative_to() guard "
f"with a Zip-slip log in the same function")
def test_t1b_dangerous_plugin_calls() -> list[TestResult]:
"""
Scan plugin directories for dangerous function calls (eval, exec).
These represent arbitrary code execution risks in plugin code.
"""
results = []
plugin_dirs = [
PROJECT_ROOT / "plugins",
PROJECT_ROOT / "plugin-repos",
]
violations: list[str] = []
files_scanned = 0
scan_errors: list[str] = []
for base in plugin_dirs:
if not base.exists():
continue
for plugin_dir in sorted(base.iterdir()):
if not plugin_dir.is_dir() or plugin_dir.name.startswith(('.', '_')):
continue
for py_file in plugin_dir.rglob("*.py"):
files_scanned += 1
try:
source = py_file.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(py_file))
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id in ("eval", "exec"):
rel = py_file.relative_to(PROJECT_ROOT)
violations.append(
f"{rel}:{node.lineno}{node.func.id}() call")
except (SyntaxError, OSError) as exc:
# A file we couldn't parse/read was never actually
# scanned for eval()/exec() -- that must block this
# test, not silently pass as if it were clean.
rel = py_file.relative_to(PROJECT_ROOT)
scan_errors.append(f"{rel}{type(exc).__name__}: {exc}")
if scan_errors:
results.append(TestResult(
"T1b", "CRITICAL",
f"{len(scan_errors)} plugin file(s) could not be scanned for eval()/exec()",
"; ".join(scan_errors[:10])
))
if violations:
results.append(TestResult(
"T1b", "CRITICAL",
f"Dangerous function calls found in plugins ({len(violations)} instance(s))",
"; ".join(violations[:10])
))
elif not scan_errors:
results.append(TestResult(
"T1b", "PASS",
"No eval()/exec() calls found in plugins",
f"{files_scanned} plugin Python files scanned"
))
return results
# ─────────────────────────────────────────────────────────────────────────────
# T2: API Surface Inventory
# ─────────────────────────────────────────────────────────────────────────────
def test_t2a_api_surface_inventory() -> TestResult:
"""
Document the API surface area.
This app intentionally has no authentication (local-only Raspberry Pi
design, documented in web_interface/app.py). This test produces an
inventory for audit purposes and warns only if the design-intent comment
is removed from app.py (which would indicate someone deleted the rationale
without adding auth, rather than a deliberate undocumented change).
"""
api_file = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
app_file = PROJECT_ROOT / "web_interface" / "app.py"
if not api_file.exists():
return TestResult("T2a", "WARNING", "api_v3.py not found", str(api_file))
api_content = api_file.read_text(encoding="utf-8")
routes = re.findall(r"@api_v3\.route\('([^']+)'", api_content)
csrf_documented = False
if app_file.exists():
app_content = app_file.read_text(encoding="utf-8")
csrf_documented = "CSRF protection disabled for local-only" in app_content
summary = (
f"{len(routes)} API routes in api_v3.py. "
f"No auth decorators (intentional local-only design). "
f"CSRF disabled: {'YES — design intent documented in app.py' if csrf_documented else 'YES — but design intent comment NOT found in app.py'}. "
f"Rate limiting: 1000/min."
)
if not csrf_documented:
return TestResult(
"T2a", "WARNING",
"CSRF is disabled but the design-intent comment is missing from app.py",
"Add the rationale comment back, or add proper CSRF protection if "
"the app is now internet-facing"
)
# There is currently no config mechanism that actually enforces the
# local-only boundary the design-intent comment describes -- app.py
# hardcodes host='0.0.0.0' unconditionally, so nothing here can confirm
# this deployment is in fact LAN-only. Reporting this as mere INFO
# understates that: an unauthenticated, CSRF-disabled API surface is a
# real risk the moment this ever runs somewhere other than a home LAN,
# documented rationale or not.
return TestResult(
"T2a", "WARNING",
"API surface has no auth and CSRF disabled; enforcement of the "
"documented local-only boundary cannot be confirmed",
summary
)
# ─────────────────────────────────────────────────────────────────────────────
# T3: Secrets & Credential Handling
# ─────────────────────────────────────────────────────────────────────────────
# Patterns that suggest real credentials (must be >8 chars, not placeholders)
_SECRET_PATTERNS = [
(r'(?i)password\s*=\s*["\'](?!none|empty|placeholder|example|test|default|""|'')[^"\']{8,}["\']', "WARNING", "password"),
(r'(?i)api[_-]?key\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING", "api_key"),
(r'(?i)secret\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING", "secret"),
# Real GitHub token pattern
(r'ghp_[a-zA-Z0-9]{36}', "CRITICAL", "github_token"),
# Generic long bearer tokens
(r'Bearer\s+[a-zA-Z0-9\-_\.]{32,}', "WARNING", "bearer_token"),
]
_TEMPLATE_SKIP_STRINGS = [
"YOUR_", "PLACEHOLDER", "_HERE", "example.com", "config_secrets.template",
"prove_security", # this file itself
]
_SCAN_DIRS = ["src", "web_interface", "scripts"]
def test_t3a_hardcoded_secrets() -> TestResult:
"""Scan source code for hardcoded credentials."""
violations: list[str] = []
for dir_name in _SCAN_DIRS:
scan_dir = PROJECT_ROOT / dir_name
if not scan_dir.exists():
continue
for py_file in scan_dir.rglob("*.py"):
# Skip test files and this script
if "test" in str(py_file).lower() or "prove_security" in str(py_file):
continue
try:
content = py_file.read_text(encoding="utf-8")
except OSError:
continue
for pattern, severity, pattern_type in _SECRET_PATTERNS:
for match in re.finditer(pattern, content):
line_content = match.group(0)
# Skip lines containing template placeholder strings.
# line_content is only used for this in-memory check --
# it must never be stored or included in output below.
if any(skip in line_content for skip in _TEMPLATE_SKIP_STRINGS):
continue
rel = py_file.relative_to(PROJECT_ROOT)
line_no = content[: match.start()].count("\n") + 1
# Redacted fingerprint lets the same finding be recognized
# across scans without ever reporting the matched
# credential itself (which would otherwise get published
# into CI logs, JSON artifacts, and PR comments -- wider
# exposure than the original leak).
fingerprint = hashlib.sha256(line_content.encode()).hexdigest()[:12]
violations.append(
f"[{severity}] {rel}:{line_no}{pattern_type} "
f"(fingerprint {fingerprint})"
)
critical_violations = [v for v in violations if "[CRITICAL]" in v]
if critical_violations:
return TestResult(
"T3a", "CRITICAL",
f"Hardcoded secrets found ({len(critical_violations)} critical)",
"; ".join(critical_violations[:5])
)
if violations:
return TestResult(
"T3a", "WARNING",
f"Potential hardcoded secrets found ({len(violations)} instance(s))",
"; ".join(violations[:5])
)
return TestResult("T3a", "PASS", "No hardcoded secrets detected",
f"Scanned {', '.join(_SCAN_DIRS)}")
def test_t3b_plaintext_password_storage() -> TestResult:
"""
Check for user account password storage without hashing.
The LEDMatrix app has no user account system, so this should produce INFO.
It would only CRITICAL if someone added user auth and stored passwords without hashing.
We require all three of: a password *variable assignment or DB operation*,
a clear storage call (INSERT / db commit / ORM save), and no hashing lib present
to avoid false positives from files that contain 'password' for WiFi handling
and '.save()' for image/file saving in unrelated functions.
"""
hashing_libs = ["bcrypt", "argon2", "pbkdf2", "scrypt",
"generate_password_hash", "hashpw", "make_password"]
# Patterns that indicate password being stored in a database / ORM context.
# Must be specific enough to avoid matching set.add(), file.save(), etc.
db_storage_patterns = ["INSERT INTO", "db.session", "session.add(", "session.commit(", "orm.save"]
password_storage_found = False
for dir_name in _SCAN_DIRS:
scan_dir = PROJECT_ROOT / dir_name
if not scan_dir.exists():
continue
for py_file in scan_dir.rglob("*.py"):
try:
content = py_file.read_text(encoding="utf-8")
except OSError:
continue
# Require DB/ORM context specifically — not just any .save() call
if ("password" in content.lower() and
any(store in content for store in db_storage_patterns) and
not any(h in content for h in hashing_libs)):
password_storage_found = True
if password_storage_found:
return TestResult(
"T3b", "CRITICAL",
"Potential plaintext password storage in database/ORM detected",
"Found password + database storage operations without a recognized hashing library"
)
return TestResult("T3b", "INFO",
"No plaintext password storage detected",
"App has no user account system — expected result")
# ─────────────────────────────────────────────────────────────────────────────
# T4: Path Traversal
# ─────────────────────────────────────────────────────────────────────────────
def test_t4a_path_traversal() -> TestResult:
"""
Verify static file serving uses send_from_directory (safe) rather than
open() with user-supplied paths. Also checks for extractall() calls that
lack the is_relative_to() guard.
"""
issues: list[str] = []
app_file = PROJECT_ROOT / "web_interface" / "app.py"
if app_file.exists():
content = app_file.read_text(encoding="utf-8")
# The file-serve route should use send_from_directory or commonpath
if "send_from_directory" not in content and "commonpath" not in content:
issues.append("app.py: file-serve routes may not use send_from_directory/commonpath")
# Check all extractall() calls have a preceding is_relative_to guard
for py_file in (PROJECT_ROOT / "src").rglob("*.py"):
try:
content = py_file.read_text(encoding="utf-8")
except OSError:
continue
if "extractall(" in content and "is_relative_to" not in content:
rel = py_file.relative_to(PROJECT_ROOT)
issues.append(f"{rel}: extractall() without is_relative_to() guard")
if issues:
return TestResult(
"T4a", "WARNING",
f"Potential path traversal patterns found ({len(issues)})",
"; ".join(issues)
)
return TestResult("T4a", "PASS",
"Path traversal mitigations verified",
"send_from_directory/commonpath used for file serving; "
"extractall() calls have is_relative_to() guards")
# ─────────────────────────────────────────────────────────────────────────────
# T5: Auth Bypass Patterns
# ─────────────────────────────────────────────────────────────────────────────
def test_t5a_auth_bypass_patterns() -> TestResult:
"""
Look for broken auth bypass patterns not the intentional no-auth design
(T2a covers that), but patterns that suggest auth was INTENDED to exist
but has an exploitable bypass: broad substring matching, debug-mode skips,
or if-True conditions.
"""
bypass_signals = [
(r'if\s+True\s*:', "if True: bypass"),
(r'if\s+debug\s*:', "debug-mode auth skip"),
(r'request\.path\s+in\s+', "substring path matching in auth (Huntarr pattern)"),
(r'EXEMPT_ROUTES\s*=', "exempt routes list"),
]
findings: list[str] = []
for dir_name in ["src", "web_interface"]:
scan_dir = PROJECT_ROOT / dir_name
if not scan_dir.exists():
continue
for py_file in scan_dir.rglob("*.py"):
try:
content = py_file.read_text(encoding="utf-8")
except OSError:
continue
for pattern, label in bypass_signals:
if re.search(pattern, content):
# Only flag if the file also contains auth-related terms
if any(auth in content.lower() for auth in
["auth", "login", "authenticate", "token", "permission"]):
rel = py_file.relative_to(PROJECT_ROOT)
findings.append(f"{rel}: {label}")
if findings:
return TestResult(
"T5a", "WARNING",
f"Potential auth bypass patterns found ({len(findings)})",
"; ".join(findings[:5])
)
return TestResult("T5a", "PASS",
"No auth bypass patterns detected",
"Checked src/ and web_interface/ for bypass signals")
# ─────────────────────────────────────────────────────────────────────────────
# T6: Docker / Container Hardening
# ─────────────────────────────────────────────────────────────────────────────
def test_t6_docker_hardening() -> TestResult:
"""Container security — skipped if no Dockerfile exists."""
dockerfile = PROJECT_ROOT / "Dockerfile"
if not dockerfile.exists():
return TestResult("T6", "SKIP",
"No Dockerfile found — container security scan not applicable",
"If Docker support is added in future, enable hadolint/trivy scanning "
"in .github/workflows/security-audit.yml")
content = dockerfile.read_text(encoding="utf-8")
issues: list[str] = []
# Check for non-root USER directive
user_lines = [l for l in content.splitlines() if l.strip().startswith("USER")]
if not user_lines or user_lines[-1].strip() == "USER root":
issues.append("Container runs as root — use USER directive to drop privileges")
# Check for pinned base image tags. A tag (even a specific version, not
# just :latest) is mutable -- the same tag can point to a different
# image later. Only a @sha256 digest is truly immutable/reproducible.
from_lines = [line for line in content.splitlines() if line.strip().startswith("FROM")]
for from_line in from_lines:
parts = from_line.split()
# FROM [--platform=<platform>] <image> [AS <name>] -- skip an
# optional --platform= flag so it's never mistaken for the image
# token itself (which would falsely report it as unpinned).
image_parts = [p for p in parts[1:] if not p.startswith("--platform=")]
if image_parts:
image = image_parts[0]
if "@sha256:" not in image:
issues.append(f"Base image not pinned to a digest: {image}")
if issues:
return TestResult("T6", "WARNING",
f"Dockerfile hardening issues ({len(issues)})",
"; ".join(issues))
return TestResult("T6", "PASS", "Dockerfile hardening checks passed", "")
# ─────────────────────────────────────────────────────────────────────────────
# Runner
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="LEDMatrix security proof tests",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--output", "-o", default=None,
help="Write JSON results to this file")
parser.add_argument("--verbose", "-v", action="store_true",
help="Show details for each check")
args = parser.parse_args()
print("=" * 60)
print("LEDMatrix Security Proof Tests")
print(f"Project root: {PROJECT_ROOT}")
print("=" * 60)
all_results: list[TestResult] = []
# Run all test groups
all_results.append(test_t1a_zip_slip_protection())
all_results.extend(test_t1b_dangerous_plugin_calls())
all_results.append(test_t2a_api_surface_inventory())
all_results.append(test_t3a_hardcoded_secrets())
all_results.append(test_t3b_plaintext_password_storage())
all_results.append(test_t4a_path_traversal())
all_results.append(test_t5a_auth_bypass_patterns())
all_results.append(test_t6_docker_hardening())
# Print results
print()
for r in all_results:
line = f" {r.icon} [{r.severity:<8}] {r.test_id}: {r.message}"
print(line)
if args.verbose and r.details:
print(f" {r.details}")
# Tally
critical = [r for r in all_results if r.severity == "CRITICAL"]
warnings = [r for r in all_results if r.severity == "WARNING"]
passed = [r for r in all_results if r.severity == "PASS"]
skipped = [r for r in all_results if r.severity == "SKIP"]
print()
print(f" Results: {len(passed)} PASS {len(warnings)} WARN "
f"{len(critical)} CRITICAL {len(skipped)} SKIP")
# Write JSON output
if args.output:
output_data = [r.to_dict() for r in all_results]
Path(args.output).write_text(
json.dumps(output_data, indent=2), encoding="utf-8"
)
print(f" Results written to: {args.output}")
if critical:
print(f"\n 🚨 {len(critical)} CRITICAL issue(s) found — blocking")
return 1
if warnings:
print(f"\n ⚠️ {len(warnings)} warning(s) found — non-blocking")
print("\n ✅ All checks passed (warnings are non-blocking)")
return 0
if __name__ == "__main__":
sys.exit(main())
-54
View File
@@ -146,60 +146,6 @@ def ensure_file_permissions(path: Path, mode: int = 0o644) -> None:
raise raise
_shared_group_gid_cache: Optional[int] = None
def get_shared_group_gid() -> Optional[int]:
"""
Return the gid that should own config/secrets files shared between the
root-run ``ledmatrix.service`` (main display) and the non-root user that
``ledmatrix-web.service`` runs as (see install_web_service.sh, which sets
``User=$SUDO_USER``).
Resolved once from the project root directory's current group (normally
the login user's group from the initial ``git clone``), since that user
is stable across reinstalls unlike any single file's ownership.
Returns:
The gid, or None if it cannot be determined.
"""
global _shared_group_gid_cache
if _shared_group_gid_cache is not None:
return _shared_group_gid_cache
try:
project_root = Path(__file__).resolve().parent.parent.parent
_shared_group_gid_cache = project_root.stat().st_gid
return _shared_group_gid_cache
except OSError:
return None
def ensure_shared_group_ownership(path: Path) -> None:
"""
Best-effort chgrp of ``path`` to the shared group (see
:func:`get_shared_group_gid`) when running as root.
Only root can change a file's group to one the calling process isn't a
member of, which is exactly the case that causes the web interface
(running as a non-root user) to get ``PermissionError`` reading files
the root-run display service just wrote with a 0o640/2775 mode: the mode
is group-readable, but without this the group is root's, not the web
user's. Silently does nothing if not running as root or on any error —
this is a hardening step, not a required one.
"""
if os.geteuid() != 0:
return
gid = get_shared_group_gid()
if gid is None:
return
try:
if path.exists() and path.stat().st_gid != gid:
os.chown(path, -1, gid)
logger.debug(f"Set shared group ownership (gid {gid}) on {path}")
except OSError as e:
logger.debug(f"Could not set shared group ownership on {path}: {e}")
def get_config_file_mode(file_path: Path) -> int: def get_config_file_mode(file_path: Path) -> int:
""" """
Return appropriate permission mode for config files. Return appropriate permission mode for config files.
+1 -25
View File
@@ -38,7 +38,6 @@ from src.config_manager_atomic import (
from src.common.permission_utils import ( from src.common.permission_utils import (
ensure_directory_permissions, ensure_directory_permissions,
ensure_file_permissions, ensure_file_permissions,
ensure_shared_group_ownership,
get_config_file_mode, get_config_file_mode,
get_config_dir_mode get_config_dir_mode
) )
@@ -235,11 +234,6 @@ class ConfigManager:
# Load and merge secrets if they exist (be permissive on errors) # Load and merge secrets if they exist (be permissive on errors)
if os.path.exists(self.secrets_path): if os.path.exists(self.secrets_path):
# Self-heal stale group ownership (e.g. the root-run display
# service wrote this file before the web user was granted
# group access) before every load attempt; no-op unless
# running as root and the group is already wrong.
ensure_shared_group_ownership(Path(self.secrets_path))
try: try:
with open(self.secrets_path, 'r') as f: with open(self.secrets_path, 'r') as f:
secrets = json.load(f) secrets = json.load(f)
@@ -369,7 +363,6 @@ class ConfigManager:
# Set proper file permissions after creation # Set proper file permissions after creation
config_path_obj = Path(self.config_path) config_path_obj = Path(self.config_path)
ensure_file_permissions(config_path_obj, get_config_file_mode(config_path_obj)) ensure_file_permissions(config_path_obj, get_config_file_mode(config_path_obj))
ensure_shared_group_ownership(config_path_obj)
self.logger.info(f"Created config.json from template at {os.path.abspath(self.config_path)}") self.logger.info(f"Created config.json from template at {os.path.abspath(self.config_path)}")
@@ -482,11 +475,6 @@ class ConfigManager:
self.logger.error(error_msg) self.logger.error(error_msg)
raise ConfigError(error_msg, config_path=path_to_load) raise ConfigError(error_msg, config_path=path_to_load)
if file_type == "secrets":
# Best-effort self-heal: no-op unless running as root and the
# group is stale (see load_config for why this can happen).
ensure_shared_group_ownership(Path(path_to_load))
try: try:
with open(path_to_load, 'r') as f: with open(path_to_load, 'r') as f:
return json.load(f) return json.load(f)
@@ -494,18 +482,7 @@ class ConfigManager:
error_msg = f"Error parsing {file_type} configuration file: {path_to_load}" error_msg = f"Error parsing {file_type} configuration file: {path_to_load}"
self.logger.error(error_msg, exc_info=True) self.logger.error(error_msg, exc_info=True)
raise ConfigError(error_msg, config_path=path_to_load) from e raise ConfigError(error_msg, config_path=path_to_load) from e
except PermissionError as e: except (IOError, OSError, PermissionError) as e:
if file_type == "secrets":
# Match load_config()'s tolerance: a secrets file the web
# process can't read (e.g. written 0640 by the root-run
# display service before the group was fixed up) shouldn't
# 500 the settings page — degrade to "no secrets" instead.
self.logger.warning(f"Secrets file not readable ({path_to_load}): {e}. Returning empty secrets.")
return {}
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
self.logger.error(error_msg, exc_info=True)
raise ConfigError(error_msg, config_path=path_to_load) from e
except (IOError, OSError) as e:
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}" error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
self.logger.error(error_msg, exc_info=True) self.logger.error(error_msg, exc_info=True)
raise ConfigError(error_msg, config_path=path_to_load) from e raise ConfigError(error_msg, config_path=path_to_load) from e
@@ -562,7 +539,6 @@ class ConfigManager:
# Ensure final file has correct permissions # Ensure final file has correct permissions
try: try:
ensure_file_permissions(path_obj, file_mode) ensure_file_permissions(path_obj, file_mode)
ensure_shared_group_ownership(path_obj)
except OSError as perm_error: except OSError as perm_error:
# If we can't set permissions but file was written, log warning but don't fail # If we can't set permissions but file was written, log warning but don't fail
self.logger.warning( self.logger.warning(
-8
View File
@@ -17,7 +17,6 @@ from enum import Enum
from src.exceptions import ConfigError from src.exceptions import ConfigError
from src.logging_config import get_logger from src.logging_config import get_logger
from src.common.permission_utils import ensure_shared_group_ownership
class SaveResultStatus(Enum): class SaveResultStatus(Enum):
@@ -412,13 +411,6 @@ class AtomicConfigManager:
# and we need root service to be able to read config.json # and we need root service to be able to read config.json
os.chmod(destination, target_mode) os.chmod(destination, target_mode)
# Also fix group ownership when this save is running as root
# (the display service): 0o640 alone only helps the non-root web
# user read a root-written secrets file if its group already
# matches the web user's group, which isn't guaranteed. See
# permission_utils.ensure_shared_group_ownership for why.
ensure_shared_group_ownership(destination)
except Exception as e: except Exception as e:
raise ConfigError(f"Error during atomic move: {e}") from e raise ConfigError(f"Error during atomic move: {e}") from e
+1 -46
View File
@@ -381,10 +381,6 @@ class DisplayController:
logger.debug("%d plugin(s) disabled in config", disabled_count) logger.debug("%d plugin(s) disabled in config", disabled_count)
logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time) 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("Total available modes: %d", len(self.available_modes))
logger.info("Available modes: %s", self.available_modes) logger.info("Available modes: %s", self.available_modes)
@@ -2847,52 +2843,11 @@ class DisplayController:
except Exception as e: except Exception as e:
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True) 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) self._resync_mode_index_after_change(previous_mode)
logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)", logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
sorted(to_add), sorted(to_remove), len(self.available_modes)) sorted(to_add), sorted(to_remove), len(self.available_modes))
return True 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: def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
"""Clamp rotation state after available_modes changed. Stays on the """Clamp rotation state after available_modes changed. Stays on the
previous mode if it survived, otherwise restarts cleanly within range.""" previous mode if it survived, otherwise restarts cleanly within range."""
-184
View File
@@ -1,184 +0,0 @@
"""
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
View File
@@ -1,85 +0,0 @@
"""
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}"
)
+26 -46
View File
@@ -59,20 +59,6 @@ except ImportError:
# flask-limiter not installed, rate limiting disabled # flask-limiter not installed, rate limiting disabled
limiter = None 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 # Import cache functions from separate module to avoid circular imports
# Initialize plugin managers - read plugins directory from config # Initialize plugin managers - read plugins directory from config
@@ -190,12 +176,7 @@ except Exception as _hm_err: # pragma: no cover - defensive startup guard
"Could not enable plugin health/metrics for web UI: %s", _hm_err "Could not enable plugin health/metrics for web UI: %s", _hm_err
) )
# Pages are served un-prefixed (the interface lives at /); the /v3 mount is a app.register_blueprint(pages_v3, url_prefix='/v3')
# 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') app.register_blueprint(api_v3, url_prefix='/api/v3')
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path) # Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
@@ -426,11 +407,7 @@ def captive_portal_redirect():
# List of paths that should NOT be redirected (allow normal operation) # List of paths that should NOT be redirected (allow normal operation)
allowed_paths = [ allowed_paths = [
'/v3', # Legacy-prefixed interface and all sub-paths '/v3', # Main interface and all sub-paths (includes /v3/setup)
'/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 '/api/v3/', # All API endpoints
'/static/', # Static files (CSS, JS, images) '/static/', # Static files (CSS, JS, images)
'/hotspot-detect.html', # iOS/macOS detection '/hotspot-detect.html', # iOS/macOS detection
@@ -629,6 +606,8 @@ def system_status_generator():
def display_preview_generator(): def display_preview_generator():
"""Generate display preview updates from snapshot file""" """Generate display preview updates from snapshot file"""
import base64 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 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 # Viewer marker: this generator only runs while the broadcaster has
@@ -670,26 +649,24 @@ def display_preview_generator():
# Only read if file is new or has been updated # Only read if file is new or has been updated
if last_modified is None or current_modified > last_modified: if last_modified is None or current_modified > last_modified:
try: try:
# The snapshot is already a PNG, written atomically by # Read and encode the image
# the display service (tmp + os.replace in with Image.open(snapshot_path) as img:
# display_manager), so pass the raw bytes straight # Convert to PNG and encode as base64
# through instead of PIL-decoding and re-encoding — buffer = io.BytesIO()
# identical payload, much less CPU on the Pi. img.save(buffer, format='PNG')
with open(snapshot_path, 'rb') as f: img_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
img_str = base64.b64encode(f.read()).decode('utf-8')
preview_data = { preview_data = {
'timestamp': time.time(), 'timestamp': time.time(),
'width': width, 'width': width,
'height': height, 'height': height,
'image': img_str 'image': img_str
} }
last_modified = current_modified last_modified = current_modified
yield preview_data yield preview_data
except OSError: except Exception: # nosec B110 - SSE preview file may be mid-write; transient error, skip this update
# Transient filesystem race (file rotated/replaced # File might be being written, skip this update
# between mtime check and read); skip this update. pass
app.logger.debug("Preview snapshot read failed; skipping frame", exc_info=True)
else: else:
# No snapshot available # No snapshot available
yield { yield {
@@ -822,8 +799,11 @@ if limiter:
limiter.limit("200 per minute")(stream_display) limiter.limit("200 per minute")(stream_display)
limiter.limit("200 per minute")(stream_logs) limiter.limit("200 per minute")(stream_logs)
# The pages blueprint's index now serves '/' directly (see the un-prefixed # Main route - redirect to v3 interface as default
# blueprint registration above), so no redirect route is needed here. @app.route('/')
def index():
"""Redirect to v3 interface"""
return redirect(url_for('pages_v3.index'))
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(): def favicon():
+10 -141
View File
@@ -961,31 +961,8 @@ def save_main_config():
return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400 return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400
current_config["sync"]["follower_position"] = pos_val current_config["sync"]["follower_position"] = pos_val
# Handle primary rotation order: must be a JSON array of plugin-id # Handle display durations
# strings. Reject anything else with a 400 rather than silently duration_fields = [k for k in data.keys() if k.endswith('_duration') or k in ['default_duration', 'transition_duration']]
# coercing, so a buggy client can't clear or corrupt the saved order.
if 'plugin_rotation_order' in data:
raw_order = data.pop('plugin_rotation_order')
try:
parsed = json.loads(raw_order) if isinstance(raw_order, str) else raw_order
except (json.JSONDecodeError, TypeError, ValueError):
return jsonify({'status': 'error',
'message': 'plugin_rotation_order must be valid JSON'}), 400
if not isinstance(parsed, list) or not all(isinstance(p, str) for p in parsed):
return jsonify({'status': 'error',
'message': 'plugin_rotation_order must be a list of plugin-id strings'}), 400
if 'display' not in current_config:
current_config['display'] = {}
current_config['display']['plugin_rotation_order'] = parsed
# Handle display durations. Popped from `data` (not just read) so
# they can never also fall through to the generic "remaining keys"
# merge near the end of this function, which would otherwise write
# them AGAIN as bogus top-level config keys (e.g. "clock_duration": 30
# sitting at config root alongside the correct
# display.display_durations.clock_duration).
duration_fields = [k for k in list(data.keys())
if k.endswith('_duration') or k in ('default_duration', 'transition_duration')]
if duration_fields: if duration_fields:
if 'display' not in current_config: if 'display' not in current_config:
current_config['display'] = {} current_config['display'] = {}
@@ -993,36 +970,8 @@ def save_main_config():
current_config['display']['display_durations'] = {} current_config['display']['display_durations'] = {}
for field in duration_fields: for field in duration_fields:
raw_value = data.pop(field) if field in data:
try: current_config['display']['display_durations'][field] = int(data[field])
int_value = int(raw_value)
except (ValueError, TypeError):
return jsonify({'status': 'error',
'message': f"Invalid duration for {field}: must be an integer"}), 400
current_config['display']['display_durations'][field] = int_value
# Per-mode durations from the Rotation & Durations page, posted as
# duration__<mode_key> (mode keys are arbitrary plugin mode names, so
# they can't use the suffix convention above). Same pop-and-validate
# treatment, for the same reason.
mode_duration_fields = [k for k in list(data.keys()) if k.startswith('duration__')]
if mode_duration_fields:
if 'display' not in current_config:
current_config['display'] = {}
if 'display_durations' not in current_config['display']:
current_config['display']['display_durations'] = {}
for field in mode_duration_fields:
raw_value = data.pop(field)
mode_key = field[len('duration__'):]
if not mode_key:
continue
try:
int_value = int(raw_value)
except (ValueError, TypeError):
return jsonify({'status': 'error',
'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400
current_config['display']['display_durations'][mode_key] = int_value
# Handle plugin configurations dynamically # Handle plugin configurations dynamically
# Any key that matches a plugin ID should be saved as plugin config # Any key that matches a plugin ID should be saved as plugin config
@@ -1690,16 +1639,6 @@ def execute_system_action():
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.warning("git stash timed out, proceeding with pull") logger.warning("git stash timed out, proceeding with pull")
# Record HEAD before the pull so dependency changes can be detected
old_head = None
try:
_pre = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, cwd=project_dir)
if _pre.returncode == 0:
old_head = _pre.stdout.strip()
except subprocess.TimeoutExpired:
logger.warning("git rev-parse timed out before pull")
# Perform the git pull # Perform the git pull
result = subprocess.run( result = subprocess.run(
['git', 'pull', '--rebase'], ['git', 'pull', '--rebase'],
@@ -1716,54 +1655,6 @@ def execute_system_action():
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}" pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
if result.stdout and "Already up to date" not in result.stdout: if result.stdout and "Already up to date" not in result.stdout:
pull_message = f"Code updated successfully.{stash_info}" pull_message = f"Code updated successfully.{stash_info}"
# Keep Python dependencies in sync automatically: if the pull
# changed a requirements file, install it now — users updating
# from the web UI (most of them) never SSH in to pip install.
# Installs go through the same root-visible path as the
# Tools-tab buttons (_pip_install_requirements).
dep_notes = []
try:
_post = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, cwd=project_dir)
new_head = _post.stdout.strip() if _post.returncode == 0 else None
if old_head and new_head and old_head != new_head:
diff = subprocess.run(
['git', 'diff', '--name-only', f'{old_head}..{new_head}'],
capture_output=True, text=True, timeout=15, cwd=project_dir)
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
for rel in ('requirements.txt', 'web_interface/requirements.txt'):
req_path = PROJECT_ROOT / rel
if rel not in changed or not req_path.exists():
continue
# Each file's install is isolated: a timeout or
# OSError (e.g. the sudo wrapper/interpreter
# missing) on one file must not abort the other.
try:
r = _pip_install_requirements(req_path, timeout=180)
if r.returncode == 0:
dep_notes.append(f"Dependencies from {rel} updated.")
else:
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install failed for %s: %s",
rel, _truncate_output(r.stdout, r.stderr))
except subprocess.TimeoutExpired:
dep_notes.append(
f"Dependency install from {rel} timed out — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install timed out for %s", rel)
except OSError as install_err:
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install errored for %s: %s",
rel, install_err)
except subprocess.TimeoutExpired:
logger.warning("post-update dependency sync timed out")
if dep_notes:
pull_message += " " + " ".join(dep_notes)
# A `git pull` restores built-in plugins (committed under # A `git pull` restores built-in plugins (committed under
# plugin-repos/) even if the user uninstalled them. Re-remove # plugin-repos/) even if the user uninstalled them. Re-remove
# any the user previously uninstalled so the update doesn't # any the user previously uninstalled so the update doesn't
@@ -1794,36 +1685,14 @@ def execute_system_action():
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'], result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
elif action == 'install_base_requirements': elif action == 'install_base_requirements':
# Base + web interface requirements: flask-compress and friends req_file = PROJECT_ROOT / 'requirements.txt'
# live in web_interface/requirements.txt, not the root file. if not req_file.exists():
req_files = [f for f in (PROJECT_ROOT / 'requirements.txt',
PROJECT_ROOT / 'web_interface' / 'requirements.txt')
if f.exists()]
if not req_files:
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'}) return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
outputs = [] result = _pip_install_requirements(req_file, timeout=120)
all_ok = True
for req_file in req_files:
label = req_file.relative_to(PROJECT_ROOT)
# Isolate each file's install: a timeout or OSError on one
# (e.g. requirements.txt) must not abort the rest of the
# loop (e.g. web_interface/requirements.txt never attempted).
try:
result = _pip_install_requirements(req_file, timeout=120)
all_ok = all_ok and result.returncode == 0
outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr))
except subprocess.TimeoutExpired:
all_ok = False
outputs.append(f"== {label} ==\nTimed out after 120s")
logger.warning("install_base_requirements timed out for %s", label)
except OSError as install_err:
all_ok = False
outputs.append(f"== {label} ==\nFailed: {install_err}")
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
return jsonify({ return jsonify({
'status': 'success' if all_ok else 'error', 'status': 'success' if result.returncode == 0 else 'error',
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed', 'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
'output': "\n".join(outputs) 'output': _truncate_output(result.stdout, result.stderr)
}) })
elif action == 'install_plugin_requirements': elif action == 'install_plugin_requirements':
active_pm = getattr(api_v3, 'plugin_manager', None) active_pm = getattr(api_v3, 'plugin_manager', None)
+2 -41
View File
@@ -397,51 +397,12 @@ def _load_display_partial():
return "Error loading partial", 500 return "Error loading partial", 500
def _load_durations_partial(): def _load_durations_partial():
"""Load rotation & durations partial. """Load display 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: try:
if pages_v3.config_manager: if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config() 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', return render_template('v3/partials/durations.html',
main_config=main_config, main_config=main_config)
duration_groups=duration_groups)
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500 return "Error loading partial", 500
-1
View File
@@ -7,7 +7,6 @@ flask>=3.1.3,<4.0.0
werkzeug>=3.1.6,<4.0.0 werkzeug>=3.1.6,<4.0.0
flask-wtf>=1.2.0 # CSRF protection (optional for local-only, but recommended) flask-wtf>=1.2.0 # CSRF protection (optional for local-only, but recommended)
flask-limiter>=3.5.0 # Rate limiting (prevent accidental abuse) flask-limiter>=3.5.0 # Rate limiting (prevent accidental abuse)
flask-compress>=1.14 # gzip/brotli response compression (big win for the large JS/HTML over WiFi)
# WebSocket support for plugins # WebSocket support for plugins
# Note: Web interface uses Server-Sent Events (SSE) for real-time updates, not WebSockets # Note: Web interface uses Server-Sent Events (SSE) for real-time updates, not WebSockets
-177
View File
@@ -413,9 +413,6 @@ a, button, input, select, textarea {
/* Responsive breakpoints */ /* Responsive breakpoints */
@media (min-width: 640px) { @media (min-width: 640px) {
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; } .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) { @media (min-width: 768px) {
@@ -424,8 +421,6 @@ a, button, input, select, textarea {
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } .md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.md\:flex { display: flex; } .md\:flex { display: flex; }
.md\:hidden { display: none; } .md\:hidden { display: none; }
.md\:block { display: block; }
.md\:w-auto { width: auto; }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@@ -436,14 +431,9 @@ a, button, input, select, textarea {
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; } .lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
.lg\:gap-x-3 { column-gap: 0.75rem; } .lg\:gap-x-3 { column-gap: 0.75rem; }
.lg\:gap-x-6 { column-gap: 1.5rem; } .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) { @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-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.xl\:grid-cols-5 { grid-template-columns: repeat(5, 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)); } .xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
@@ -456,9 +446,6 @@ a, button, input, select, textarea {
} }
@media (min-width: 1536px) { @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-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.2xl\:grid-cols-6 { grid-template-columns: repeat(6, 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)); } .2xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
@@ -469,129 +456,6 @@ a, button, input, select, textarea {
.2xl\:space-x-8 > * + * { margin-left: 2rem; } .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 loading states */
.htmx-request .loading { .htmx-request .loading {
display: inline-block; display: inline-block;
@@ -1356,44 +1220,3 @@ button.bg-white {
[data-theme="dark"] .power-warning-banner-dismiss { [data-theme="dark"] .power-warning-banner-dismiss {
color: #fca5a5; 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);
}
}
+9 -281
View File
@@ -1,4 +1,4 @@
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */ /* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
// LED Matrix v3 JavaScript // LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration // Additional helpers for HTMX and Alpine.js integration
@@ -12,8 +12,8 @@ window.showNotification = function(message, type = 'info') {
}); });
document.dispatchEvent(event); document.dispatchEvent(event);
} else { } else {
// Fallback notification — user-facing last resort, so never gated // Fallback notification
console.info(`${type}: ${message}`); console.log(`${type}: ${message}`);
} }
}; };
@@ -49,111 +49,6 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
// Not JSON, ignore // 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() {
try { sessionStorage.setItem('ledmatrix-restart-pending', '1'); } catch { /* private browsing */ }
const banner = document.getElementById('restart-pending-banner');
if (banner) banner.style.display = 'block';
};
window.dismissRestartPending = function() {
try { sessionStorage.removeItem('ledmatrix-restart-pending'); } 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');
if (banner) banner.style.display = 'block';
}
} catch { /* no-op */ }
}); });
// SSE reconnection helper — closes and reopens both SSE streams, // SSE reconnection helper — closes and reopens both SSE streams,
@@ -351,13 +246,13 @@ window.performanceMonitor = {
logMetrics: function() { logMetrics: function() {
const metrics = this.getMetrics(); const metrics = this.getMetrics();
console.group('Performance Metrics'); console.group('Performance Metrics');
debugLog('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms'); console.log('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
debugLog('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms'); console.log('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
debugLog('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms'); console.log('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
debugLog('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms'); console.log('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
debugLog('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB'); console.log('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
if (Object.keys(metrics.measures || {}).length > 0) { if (Object.keys(metrics.measures || {}).length > 0) {
debugLog('Custom Measures:', metrics.measures); console.log('Custom Measures:', metrics.measures);
} }
console.groupEnd(); console.groupEnd();
} }
@@ -378,170 +273,3 @@ document.addEventListener('DOMContentLoaded', function() {
}, 100); }, 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.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

-356
View File
@@ -1,356 +0,0 @@
/* 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
@@ -1,256 +0,0 @@
/* 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,14 +173,7 @@
function setActiveTab(tab) { function setActiveTab(tab) {
var data = getAppData(); var data = getAppData();
if (data) { if (data) { data.activeTab = tab; return true; }
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; return false;
} }
@@ -209,7 +209,6 @@
const removeButton = document.createElement('button'); const removeButton = document.createElement('button');
removeButton.type = 'button'; removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1'; removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.setAttribute('aria-label', 'Remove feed');
removeButton.addEventListener('click', function() { removeButton.addEventListener('click', function() {
window.removeCustomFeedRow(this); window.removeCustomFeedRow(this);
}); });
@@ -334,7 +333,6 @@
const removeButton = document.createElement('button'); const removeButton = document.createElement('button');
removeButton.type = 'button'; removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1'; removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.setAttribute('aria-label', 'Remove feed');
removeButton.addEventListener('click', function() { removeButton.addEventListener('click', function() {
window.removeCustomFeedRow(this); window.removeCustomFeedRow(this);
}); });
@@ -406,10 +404,7 @@
if (!file) return; if (!file) return;
const formData = new FormData(); const formData = new FormData();
// Backend contract (api_v3.upload_plugin_asset): field must be named formData.append('file', file);
// "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); formData.append('plugin_id', pluginId);
fetch('/api/v3/plugins/assets/upload', { fetch('/api/v3/plugins/assets/upload', {
@@ -426,8 +421,8 @@
return response.json(); return response.json();
}) })
.then(data => { .then(data => {
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) { if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
const uploadedFile = data.uploaded_files[0]; const uploadedFile = data.data.files[0];
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`); const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
if (row) { if (row) {
const logoCell = row.querySelector('td:nth-child(3)'); const logoCell = row.querySelector('td:nth-child(3)');
@@ -500,6 +495,8 @@
// Append container to logoCell // Append container to logoCell
logoCell.appendChild(container); logoCell.appendChild(container);
} }
// Allow re-uploading the same file
event.target.value = '';
} else { } else {
const notifyFn = window.showNotification || alert; const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error'); notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
@@ -509,12 +506,6 @@
console.error('Upload error:', error); console.error('Upload error:', error);
const notifyFn = window.showNotification || alert; const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + error.message, 'error'); 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 = '';
}); });
}; };
@@ -58,10 +58,6 @@
// Track active notifications // Track active notifications
let activeNotifications = []; 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; let notificationCounter = 0;
/** /**
@@ -117,7 +113,6 @@
// Remove from tracking array // Remove from tracking array
activeNotifications = activeNotifications.filter(id => id !== notificationId); activeNotifications = activeNotifications.filter(id => id !== notificationId);
actionCallbacks.delete(notificationId);
} }
/** /**
@@ -163,20 +158,6 @@
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`; 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) { if (dismissible) {
html += ` html += `
<button type="button" <button type="button"
@@ -246,17 +227,6 @@
removeNotification(notificationId); 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 * Clear all notifications
*/ */
@@ -292,11 +262,9 @@
} }
}); });
// 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') { window.showNotification = function(message, type = 'info') {
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {})); return showNotification(message, { type: type });
}; };
// Initialize container on load // Initialize container on load
@@ -1,225 +0,0 @@
/**
* 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 };
})();
-24
View File
@@ -1,24 +0,0 @@
{
"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
@@ -1 +0,0 @@
!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+"`")});
@@ -1 +0,0 @@
!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
@@ -1 +0,0 @@
.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>
<div class="footer"> <div class="footer">
<a href="/">Open Full Interface</a> <a href="/v3">Open Full Interface</a>
</div> </div>
<script> <script>
+217 -124
View File
@@ -22,7 +22,7 @@
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule 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>) (<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). and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy).
Check the <a href="#" @click.prevent="activeTab = 'logs'" class="underline font-medium">Logs tab</a> for the full error. Check the <a href="/v3/logs" class="underline font-medium">Logs tab</a> for the full error.
</p> </p>
</div> </div>
@@ -47,7 +47,7 @@
name="rows" name="rows"
value="{{ main_config.display.hardware.rows or 32 }}" value="{{ main_config.display.hardware.rows or 32 }}"
min="1" min="1"
max="128" max="64"
class="form-control"> class="form-control">
</div> </div>
@@ -85,14 +85,7 @@
</div> </div>
</div> </div>
<!-- Live total-resolution readout: width = cols x chain_length, height = rows x parallel --> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<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"> <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> <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"> <div class="flex items-center space-x-2">
@@ -116,7 +109,9 @@
<option value="regular-pi1" {% if main_config.display.hardware.hardware_mapping == "regular-pi1" %}selected{% endif %}>Regular Pi1</option> <option value="regular-pi1" {% if main_config.display.hardware.hardware_mapping == "regular-pi1" %}selected{% endif %}>Regular Pi1</option>
</select> </select>
</div> </div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence"> <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> <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"> <select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
@@ -128,29 +123,7 @@
<option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option> <option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option>
</select> </select>
</div> </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"> <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> <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"> <select id="multiplexing" name="multiplexing" class="form-control">
@@ -282,6 +255,47 @@
class="form-control"> class="form-control">
</div> </div>
</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 --> <!-- Display Options -->
<div class="bg-gray-50 rounded-lg p-4"> <div class="bg-gray-50 rounded-lg p-4">
@@ -354,36 +368,6 @@
</div> </div>
</div> </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 --> <!-- Vegas Scroll Mode Settings -->
<div class="bg-gray-50 rounded-lg p-4 mt-6"> <div class="bg-gray-50 rounded-lg p-4 mt-6">
@@ -470,48 +454,6 @@
</div> </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>
<!-- Multi-Display Sync Settings --> <!-- Multi-Display Sync Settings -->
<div class="bg-gray-50 rounded-lg p-4 mt-6"> <div class="bg-gray-50 rounded-lg p-4 mt-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@@ -641,31 +583,182 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
}); });
} }
// Initialize plugin order list via the shared drag-and-drop module // Initialize plugin order list
// (static/v3/js/widgets/plugin-order-list.js) — the same component the function initPluginOrderList() {
// Durations tab uses for the primary rotation order.
function initPluginOrderList(attempt) {
const container = document.getElementById('vegas_plugin_order'); const container = document.getElementById('vegas_plugin_order');
if (!container) return; if (!container) return;
if (!window.PluginOrderList) {
// Widget script is deferred; retry briefly, then surface a real // Fetch available plugins
// error instead of waiting forever. fetch('/api/v3/plugins/installed')
if ((attempt || 0) < 50) { .then(response => response.json())
setTimeout(function() { initPluginOrderList((attempt || 0) + 1); }, 100); .then(data => {
} else { // Handle both {data: {plugins: []}} and {plugins: []} response formats
container.textContent = 'Could not load the reorder widget — reload the page to try again.'; const allPlugins = data.data?.plugins || data.plugins || [];
container.className = 'text-sm text-red-500'; if (!allPlugins || allPlugins.length === 0) {
} container.innerHTML = '<p class="text-sm text-gray-500 italic">No plugins available</p>';
return; return;
} }
window.PluginOrderList.init({
containerId: 'vegas_plugin_order', // Get current order and exclusions
orderInputId: 'vegas_plugin_order_value', const orderInput = document.getElementById('vegas_plugin_order_value');
excludedInputId: 'vegas_excluded_plugins_value', const excludedInput = document.getElementById('vegas_excluded_plugins_value');
showVegasModeBadge: true 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);
}
});
orderInput.value = JSON.stringify(order);
excludedInput.value = JSON.stringify(excluded);
}
// Initialize on DOM ready // Initialize on DOM ready
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -1,8 +1,8 @@
{% import 'v3/partials/_macros.html' as ui %} {% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Rotation &amp; Durations</h2> <h2 class="text-lg font-semibold text-gray-900">Display 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> <p class="mt-1 text-sm text-gray-600">Configure how long each screen is shown before switching. Values in seconds.</p>
</div> </div>
{{ ui.settings_filter() }} {{ ui.settings_filter() }}
@@ -16,53 +16,22 @@
novalidate novalidate
onsubmit="fixInvalidNumberInputs(this); return true;"> onsubmit="fixInvalidNumberInputs(this); return true;">
<!-- Primary rotation order: drag to reorder which plugin shows first, <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
second, ... in the normal display rotation. Saved as {% for key, value in main_config.display.display_durations.items() %}
display.plugin_rotation_order and applied by the display <div class="form-group" id="setting-durations-{{ key }}" data-setting-key="display.display_durations.{{ key }}">
controller on startup and live plugin enable/disable. --> <label for="duration_{{ key }}" class="block text-sm font-medium text-gray-700">
<div class="bg-gray-50 rounded-lg p-4"> {{ 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) }}
<h3 class="text-md font-medium text-gray-900 mb-1">Rotation Order</h3> </label>
<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> <input type="number"
<div id="rotation_plugin_order" class="space-y-2 bg-white rounded-lg p-3 border border-gray-200"> id="duration_{{ key }}"
<p class="text-sm text-gray-500 italic">Loading plugins…</p> name="{{ key }}"
</div> value="{{ value }}"
<input type="hidden" id="rotation_plugin_order_value" name="plugin_rotation_order" min="5"
value='{{ main_config.display.get("plugin_rotation_order", [])|tojson }}'> max="600"
</div> class="form-control">
{% 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> </div>
{% endfor %} {% endfor %}
</div> </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 --> <!-- Submit Button -->
<div class="flex justify-end"> <div class="flex justify-end">
@@ -74,34 +43,3 @@
</div> </div>
</form> </form>
</div> </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>
@@ -61,149 +61,6 @@
}()); }());
</script> </script>
<!-- Getting Started checklist: non-gating, dismissible (localStorage), items
auto-check from existing config/endpoints — no new persisted state.
Known heuristic limits (acceptable, disclosed): values left at legitimate
defaults (e.g. a user actually in Tampa) read as "not done". -->
{% 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 _loc_done = (main_config.timezone and main_config.timezone != 'America/New_York')
or (_loc.city and _loc.city != 'Tampa') %}
<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="{{ '1' if _loc_done else '0' }}" data-tab="general">
<i class="far fa-square mr-2"></i>Set your timezone and location (General tab)</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();
// 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="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2> <h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
@@ -364,10 +221,7 @@
<h3 class="text-md font-medium text-gray-900 mb-4"> <h3 class="text-md font-medium text-gray-900 mb-4">
<i class="fas fa-desktop"></i> Live Display Preview <i class="fas fa-desktop"></i> Live Display Preview
</h3> </h3>
<!-- overflow-x-auto: on narrow screens a wide preview scrolls at its <div class="bg-gray-900 rounded-lg p-6 border border-gray-700" style="position: relative;">
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="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> <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"> <img id="displayImage" style="image-rendering: pixelated; display: block;" alt="LED Matrix Display">
@@ -893,12 +893,6 @@
<p class="mt-1 text-sm text-gray-600">{{ plugin.description or 'Plugin configuration' }}</p> <p class="mt-1 text-sm text-gray-600">{{ plugin.description or 'Plugin configuration' }}</p>
</div> </div>
<div class="flex items-center space-x-4"> <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"> <label class="flex items-center cursor-pointer">
<input type="checkbox" <input type="checkbox"
id="plugin-enabled-{{ plugin.id }}" id="plugin-enabled-{{ plugin.id }}"
@@ -1011,53 +1005,13 @@
{# Use property order if defined, otherwise use natural order #} {# Use property order if defined, otherwise use natural order #}
{# Skip 'enabled' field - it's handled by the header toggle #} {# 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 %} {% 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 %} {% for key in property_order %}
{% if key in schema.properties and key != 'enabled' %} {% if key in schema.properties and key != 'enabled' %}
{% set prop = schema.properties[key] %} {% set prop = schema.properties[key] %}
{% set is_object = prop.type is defined and 'object' in prop.type %} {% set value = config[key] if key in config else none %}
{% if prop.get('x-advanced') and not is_object %} {{ render_field(key, prop, value, '', plugin.id) }}
{% set tiers.advanced = tiers.advanced + [key] %}
{% else %}
{% set tiers.basic = tiers.basic + [key] %}
{% endif %}
{% endif %} {% endif %}
{% endfor %} {% 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 %} {% else %}
{# No schema - render simple form from config #} {# No schema - render simple form from config #}
{% if config %} {% if config %}
@@ -466,6 +466,65 @@
</div> </div>
</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 --> <!-- On-Demand Modal moved to base.html so it's always available -->
<style> <style>