Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deff6bc5f0 | ||
|
|
b0a404ef26 | ||
|
|
73a5304194 |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -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
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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}"
|
|
||||||
)
|
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
||||||
};
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -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 };
|
|
||||||
})();
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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)})});
|
|
||||||
@@ -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}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">—</strong>
|
|
||||||
<span class="text-gray-400">(columns × chain length wide, rows × 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 (1–100%).\nLower is dimmer, higher is brighter. Recommended: 70–90 indoors, 90–100 in bright rooms.', 'Brightness') }}</label>
|
<label for="brightness" class="block text-sm font-medium text-gray-700">Brightness{{ ui.help_tip('Overall LED brightness (1–100%).\nLower is dimmer, higher is brighter. Recommended: 70–90 indoors, 90–100 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 & Display Options (15)
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">Multiplexing, panel variants, PWM tuning, and display options — 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 — e.g. two 64×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 (2–8).\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 — chained panels (side by side)</option>
|
||||||
|
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical — 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 — e.g. two 64×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 (2–8).\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 — chained panels (side by side)</option>
|
|
||||||
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical — 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 & 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: 5–600. 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–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: 5–600. 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 — 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>
|
||||||
|
|||||||