mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-16 09:38:38 +00:00
Compare commits
18 Commits
claude/tes
...
313e35a98f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
313e35a98f | ||
|
|
122e6d6863 | ||
|
|
d488e8a2ad | ||
|
|
b9dcbb5152 | ||
|
|
f27fd260f7 | ||
|
|
eedf680a8c | ||
|
|
ac3a15bfaa | ||
|
|
4961697251 | ||
|
|
cac9644b6d | ||
|
|
f96fdd9f24 | ||
|
|
35c540d0e0 | ||
|
|
7603909c59 | ||
|
|
34b186125a | ||
|
|
ea95f37d73 | ||
|
|
0c7d03a476 | ||
|
|
321a87f734 | ||
|
|
9930bd33b1 | ||
|
|
713539e491 |
7
.codacy.yml
Normal file
7
.codacy.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- "plugin-repos/**"
|
||||
- "plugins/**"
|
||||
- "assets/**"
|
||||
- "test/**"
|
||||
- "scripts/debug/**"
|
||||
33
.github/workflows/test.yml
vendored
Normal file
33
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
plugin-safety:
|
||||
name: Plugin safety harness + unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install RGBMatrixEmulator
|
||||
|
||||
- name: Run harness + visual rendering tests
|
||||
run: |
|
||||
pytest --no-cov \
|
||||
test/plugins/test_harness.py \
|
||||
test/plugins/test_visual_rendering.py \
|
||||
test/plugins/test_plugin_matrix.py
|
||||
136
docs/plugin-safety-harness.md
Normal file
136
docs/plugin-safety-harness.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Plugin Safety Harness
|
||||
|
||||
Renders a plugin across **every declared screen (mode)** and **a spread of
|
||||
matrix sizes**, and fails if any combination crashes, draws past the panel edge,
|
||||
or — for plugins that ship golden images — drifts visually. The goal: change a
|
||||
plugin without breaking a size or screen you didn't think to test.
|
||||
|
||||
## Sizes: a sample, not a fixed list
|
||||
|
||||
There is **no fixed set of supported panel sizes** — an RGB matrix build can be
|
||||
any width/height and configuration (square, rectangle, 2×2, 4×4, 8×2, long
|
||||
strips, tall stacks). Plugins are expected to read dimensions dynamically
|
||||
(`self.display_manager.matrix.width/height`) and lay themselves out
|
||||
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
|
||||
here.
|
||||
|
||||
The harness therefore renders against a **representative sample** that spans the
|
||||
axes of variation (`DEFAULT_TEST_SIZES` in `src/plugin_system/testing/sizes.py`),
|
||||
not an authoritative list:
|
||||
|
||||
Each module is 64×32; entries are real panel-grid arrangements (cols × rows):
|
||||
|
||||
| Size | Grid | Why it's in the sample |
|
||||
|---------|------|--------------------------------------------|
|
||||
| 64×32 | 1×1 | single panel — tightest common rectangle |
|
||||
| 128×32 | 2×1 | the baseline most plugins are tuned for |
|
||||
| 64×64 | 1×2 | stacked — tall-narrow centering |
|
||||
| 128×64 | 2×2 | block — icon scaling / vertical centering |
|
||||
| 256×32 | 4×1 | long strip — wide horizontal layout |
|
||||
| 128×96 | 2×3 | tall — vertical overflow |
|
||||
| 256×128 | 4×4 | large block — both dimensions big at once |
|
||||
|
||||
**Override the sizes entirely** to test your actual hardware (or any shape):
|
||||
|
||||
```bash
|
||||
# CLI — one-off:
|
||||
python scripts/check_plugin.py --plugin clock-simple --sizes 8x16,64x64,256x32
|
||||
|
||||
# pytest — force every plugin onto your panel(s):
|
||||
LEDMATRIX_TEST_SIZES="8x16,128x128" pytest test/plugins/test_plugin_matrix.py
|
||||
|
||||
# Per-plugin — declare the shapes a plugin targets in its test/harness.json:
|
||||
# { "sizes": [[8, 16], [64, 64]] }
|
||||
```
|
||||
|
||||
Precedence: `LEDMATRIX_TEST_SIZES` env (global) → per-plugin `harness.json`
|
||||
`sizes` → the default sample. Bounds checking adapts to whatever sizes a run
|
||||
uses — the backing canvas is padded out to the **largest** panel in the run, so
|
||||
a coordinate meant for a big build is still caught when rendering a small one.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Functional + bounds check across all sizes/screens:
|
||||
python scripts/check_plugin.py --plugin clock-simple
|
||||
|
||||
# Every discovered plugin:
|
||||
python scripts/check_plugin.py --all
|
||||
|
||||
# Dump PNGs to eyeball each size/screen:
|
||||
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
||||
```
|
||||
|
||||
Exit code is non-zero if any `(plugin, size, screen)` fails. Plugins are
|
||||
discovered in `plugin-repos/` and `plugins/` (override with `--plugin-dir`).
|
||||
|
||||
## What it checks (Phase 1 — always on)
|
||||
|
||||
1. **Loads** and builds its mode list.
|
||||
2. **Renders every screen** at every size without raising. `update()` may fail
|
||||
(no network in CI) and is tolerated; a crash in `display()` is a failure —
|
||||
`display()` must handle the no-data state.
|
||||
3. **Bounds**: nothing is drawn past the right/bottom edge. Implemented by
|
||||
`BoundsCheckingDisplayManager`, which backs the declared panel with an
|
||||
oversized canvas and flags any pixels that land in the margin. (Left/top
|
||||
overflow at negative coordinates and BDF text are not flagged — golden images
|
||||
cover those.)
|
||||
|
||||
## Golden images (Phase 2 — opt-in per plugin)
|
||||
|
||||
A plugin opts in by committing reference PNGs and (usually) a small harness spec:
|
||||
|
||||
```
|
||||
plugins/<id>/test/harness.json # how to render deterministically
|
||||
plugins/<id>/test/fixtures/mock.json # optional cached data
|
||||
plugins/<id>/test/golden/<WxH>/<mode>.png
|
||||
```
|
||||
|
||||
`test/harness.json` keys (all optional):
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { "timezone": "UTC" },
|
||||
"mock_data": "fixtures/mock.json",
|
||||
"freeze_time": "2025-08-01 15:25:00",
|
||||
"skip_update": false,
|
||||
"sizes": [[128, 32], [128, 64]]
|
||||
}
|
||||
```
|
||||
|
||||
Generate / refresh goldens after an intentional visual change, then review the
|
||||
diff before committing:
|
||||
|
||||
```bash
|
||||
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
||||
--config '{"timezone":"UTC"}' --freeze-time "2025-08-01 15:25:00"
|
||||
```
|
||||
|
||||
Comparison is exact by default (`compare_images` in `harness.py` accepts a
|
||||
tolerance for known anti-aliasing noise). Determinism requires a pinned Pillow
|
||||
and the bundled fonts — keep both stable when regenerating goldens.
|
||||
|
||||
## Tests & CI
|
||||
|
||||
- `test/plugins/test_harness.py` — unit tests for bounds detection, image
|
||||
comparison, and mode enumeration (run anywhere).
|
||||
- `test/plugins/test_plugin_matrix.py` — parametrized over discovered plugins ×
|
||||
sizes × screens; honors each plugin's `test/harness.json` and goldens. Skips
|
||||
when no plugins are present (e.g. a fresh core checkout); set
|
||||
`LEDMATRIX_REQUIRE_PLUGINS=1` in a pipeline where plugins must be present to
|
||||
turn an empty discovery into a hard failure instead. Point it at the monorepo
|
||||
with `LEDMATRIX_PLUGINS_DIR=/path/to/ledmatrix-plugins/plugins`.
|
||||
- `.github/workflows/test.yml` — runs the harness + visual tests on every PR.
|
||||
|
||||
The plugin monorepo has its own `Plugin Safety` workflow that runs this harness
|
||||
against changed plugins on every PR.
|
||||
|
||||
## Developer workflow
|
||||
|
||||
1. Change the plugin on a branch.
|
||||
2. `python scripts/check_plugin.py --plugin <id> --out-dir /tmp/preview` and
|
||||
eyeball the PNGs.
|
||||
3. Intentional visual change? `--update-golden`, review diffs, commit goldens.
|
||||
4. (Monorepo) bump `manifest.json` version and let the pre-commit hook sync
|
||||
`plugins.json`.
|
||||
5. Push — CI re-runs the harness across all sizes and gates the PR.
|
||||
@@ -10,6 +10,98 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component
|
||||
|
||||
## Available Core Widgets
|
||||
|
||||
### Plugin File Manager Widget (`plugin-file-manager`)
|
||||
|
||||
Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
|
||||
|
||||
`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed.
|
||||
|
||||
**Schema Configuration:**
|
||||
```json
|
||||
{
|
||||
"file_manager": {
|
||||
"type": "null",
|
||||
"title": "Data Files",
|
||||
"x-widget": "plugin-file-manager",
|
||||
"x-widget-config": {
|
||||
"actions": {
|
||||
"list": "list-files",
|
||||
"get": "get-file",
|
||||
"save": "save-file",
|
||||
"upload": "upload-file",
|
||||
"delete": "delete-file",
|
||||
"create": "create-file",
|
||||
"toggle": "toggle-category"
|
||||
},
|
||||
"upload_hint": "JSON files with day numbers 1–365 as keys",
|
||||
"directory_label": "my_data/",
|
||||
"create_fields": [
|
||||
{ "key": "category_name", "label": "Category Name",
|
||||
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
|
||||
"hint": "Lowercase letters, numbers, underscores" },
|
||||
{ "key": "display_name", "label": "Display Name",
|
||||
"placeholder": "e.g., My Words", "hint": "Optional" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
||||
|
||||
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
||||
|
||||
**Used by:** of-the-day
|
||||
|
||||
---
|
||||
|
||||
### Time Picker Widget (`time-picker`)
|
||||
|
||||
Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration.
|
||||
|
||||
**Schema Configuration:**
|
||||
```json
|
||||
{
|
||||
"target_time": {
|
||||
"type": "string",
|
||||
"x-widget": "time-picker",
|
||||
"default": "00:00",
|
||||
"x-options": {
|
||||
"placeholder": "Select time",
|
||||
"clearable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Used by:** countdown
|
||||
|
||||
---
|
||||
|
||||
### File Upload Single Widget (`file-upload-single`)
|
||||
|
||||
Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins/<plugin_id>/uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema.
|
||||
|
||||
**Schema Configuration:**
|
||||
```json
|
||||
{
|
||||
"image_path": {
|
||||
"type": "string",
|
||||
"x-widget": "file-upload-single",
|
||||
"x-upload-config": {
|
||||
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
|
||||
"max_size_mb": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows.
|
||||
|
||||
**Used by:** countdown
|
||||
|
||||
---
|
||||
|
||||
### File Upload Widget (`file-upload`)
|
||||
|
||||
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
"Pillow>=10.0.0",
|
||||
"PyYAML>=6.0",
|
||||
"requests>=2.31.0"
|
||||
]
|
||||
],
|
||||
"local_only": true
|
||||
}
|
||||
|
||||
8
requirements-test.txt
Normal file
8
requirements-test.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
# Test-only dependencies for the plugin safety harness and pytest suite.
|
||||
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||
# Upper bounds pin the major version so a new release can't silently change
|
||||
# golden-image / time-sensitive test behavior between CI runs.
|
||||
pytest>=7.4,<9
|
||||
pytest-cov>=4.1,<7
|
||||
jsonschema>=4.0,<5 # manifest validation
|
||||
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||
217
scripts/check_plugin.py
Normal file
217
scripts/check_plugin.py
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Plugin safety checker.
|
||||
|
||||
Renders a plugin across every declared screen (mode) and every supported matrix
|
||||
size, and fails if any screen crashes, overflows the panel, or (for plugins with
|
||||
committed golden images) drifts visually.
|
||||
|
||||
Usage:
|
||||
# Functional + bounds check across all sizes/modes:
|
||||
python scripts/check_plugin.py --plugin clock-simple
|
||||
|
||||
# Every discovered plugin:
|
||||
python scripts/check_plugin.py --all
|
||||
|
||||
# Dump PNGs for each size/mode so you can eyeball them:
|
||||
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
||||
|
||||
# Refresh committed golden images after an intentional visual change:
|
||||
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
||||
--mock-data plugins/clock-simple/test/fixtures/mock.json
|
||||
|
||||
Exit code is non-zero if any (plugin, size, mode) fails.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
os.environ['EMULATOR'] = 'true'
|
||||
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
find_plugin_dir, load_config_defaults,
|
||||
)
|
||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
DEFAULT_TEST_SIZES, parse_size_token, safe_mode_filename, size_label,
|
||||
)
|
||||
|
||||
logger = get_logger("[Check Plugin]")
|
||||
|
||||
DEFAULT_SEARCH_DIRS = [
|
||||
str(PROJECT_ROOT / 'plugins'),
|
||||
str(PROJECT_ROOT / 'plugin-repos'),
|
||||
]
|
||||
|
||||
|
||||
def discover_plugins(search_dirs: List[str]) -> List[str]:
|
||||
"""All plugin ids found across the search dirs (dirs containing manifest.json)."""
|
||||
found = []
|
||||
for d in search_dirs:
|
||||
base = Path(d)
|
||||
if not base.exists():
|
||||
continue
|
||||
for child in sorted(base.iterdir()):
|
||||
if (child / 'manifest.json').exists() and child.name not in found:
|
||||
found.append(child.name)
|
||||
return found
|
||||
|
||||
|
||||
def parse_sizes(spec: Optional[str]):
|
||||
if not spec:
|
||||
return DEFAULT_TEST_SIZES
|
||||
sizes = []
|
||||
for token in spec.split(','):
|
||||
if not token.strip():
|
||||
continue
|
||||
try:
|
||||
sizes.append(parse_size_token(token))
|
||||
except ValueError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
return sizes
|
||||
|
||||
|
||||
def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
config: Dict, run_update: bool, out_dir: Optional[Path],
|
||||
update_golden: bool, golden_dir_override: Optional[Path],
|
||||
freeze_time: Optional[str]) -> List[RenderResult]:
|
||||
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
|
||||
if not plugin_dir:
|
||||
logger.error("Plugin '%s' not found in: %s", plugin_id, search_dirs)
|
||||
return [RenderResult(plugin_id, 0, 0, "<not-found>", error="plugin directory not found")]
|
||||
|
||||
# Start from config_schema defaults so plugins behave like a real install.
|
||||
full_config = {"enabled": True}
|
||||
full_config.update(load_config_defaults(plugin_dir))
|
||||
full_config.update(config)
|
||||
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
|
||||
mock_data=mock_data, sizes=sizes, run_update=run_update,
|
||||
freeze_time=freeze_time,
|
||||
)
|
||||
|
||||
golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
|
||||
if update_golden:
|
||||
written = write_goldens(results, golden_dir)
|
||||
logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
|
||||
else:
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
if out_dir:
|
||||
for r in results:
|
||||
if r.image is None:
|
||||
continue
|
||||
dest = out_dir / plugin_id / size_label(r.width, r.height)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
"""Print a per-plugin grid. Returns True if everything passed."""
|
||||
everything_ok = True
|
||||
for plugin_id, results in all_results.items():
|
||||
print(f"\n=== {plugin_id} ===")
|
||||
for r in results:
|
||||
if r.ok:
|
||||
status = "PASS"
|
||||
detail = ""
|
||||
if r.golden_checked:
|
||||
detail = " (golden ✓)"
|
||||
if r.update_error is not None:
|
||||
detail += f" (update warn: {r.update_error})"
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
status, detail = "FAIL", f" error={r.error}"
|
||||
elif r.overflow is not None:
|
||||
status, detail = "FAIL", f" overflow bbox={r.overflow}"
|
||||
elif r.golden_ok is False:
|
||||
status = "FAIL"
|
||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
print()
|
||||
return everything_ok
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Check a plugin renders safely across sizes & screens")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument('--plugin', '-p', help='Plugin id to check')
|
||||
group.add_argument('--all', action='store_true', help='Check every discovered plugin')
|
||||
parser.add_argument('--plugin-dir', '-d', default=None, help='Directory to search for plugins')
|
||||
parser.add_argument('--sizes', default=None, help='Comma-separated WxH list (default: all supported)')
|
||||
parser.add_argument('--config', '-c', default='{}', help='Plugin config overrides as JSON')
|
||||
parser.add_argument('--mock-data', '-m', default=None, help='Path to JSON file with mock cache data')
|
||||
parser.add_argument('--out-dir', '-o', default=None, help='Also dump rendered PNGs here')
|
||||
parser.add_argument('--skip-update', action='store_true', help='Skip calling update()')
|
||||
parser.add_argument('--update-golden', action='store_true', help='Write/refresh golden images')
|
||||
parser.add_argument('--golden-dir', default=None, help='Override golden dir (default: <plugin>/test/golden)')
|
||||
parser.add_argument('--freeze-time', default=None,
|
||||
help='Freeze wall clock, e.g. "2025-08-01 15:25:00" (for time-dependent plugins)')
|
||||
args = parser.parse_args()
|
||||
|
||||
search_dirs = [args.plugin_dir] if args.plugin_dir else DEFAULT_SEARCH_DIRS
|
||||
sizes = parse_sizes(args.sizes)
|
||||
|
||||
try:
|
||||
config = json.loads(args.config)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Invalid --config JSON: %s", e)
|
||||
return 2
|
||||
if not isinstance(config, dict):
|
||||
logger.error("--config must be a JSON object, got %s", type(config).__name__)
|
||||
return 2
|
||||
|
||||
mock_data = {}
|
||||
if args.mock_data:
|
||||
mock_path = Path(args.mock_data)
|
||||
if not mock_path.exists():
|
||||
logger.error("Mock data file not found: %s", args.mock_data)
|
||||
return 2
|
||||
with open(mock_path) as f:
|
||||
mock_data = json.load(f)
|
||||
if not isinstance(mock_data, dict):
|
||||
logger.error("--mock-data must be a JSON object (key -> cache value), got %s",
|
||||
type(mock_data).__name__)
|
||||
return 2
|
||||
|
||||
plugin_ids = discover_plugins(search_dirs) if args.all else [args.plugin]
|
||||
if not plugin_ids:
|
||||
logger.error("No plugins found in: %s", search_dirs)
|
||||
return 2
|
||||
|
||||
out_dir = Path(args.out_dir) if args.out_dir else None
|
||||
golden_dir_override = Path(args.golden_dir) if args.golden_dir else None
|
||||
|
||||
all_results: Dict[str, List[RenderResult]] = {}
|
||||
for plugin_id in plugin_ids:
|
||||
all_results[plugin_id] = check_one(
|
||||
plugin_id=plugin_id, search_dirs=search_dirs, sizes=sizes,
|
||||
mock_data=mock_data, config=config, run_update=not args.skip_update,
|
||||
out_dir=out_dir, update_golden=args.update_golden,
|
||||
golden_dir_override=golden_dir_override, freeze_time=args.freeze_time,
|
||||
)
|
||||
|
||||
# When refreshing goldens we skip drift comparison, but a crash or overflow
|
||||
# still means the plugin is broken — never let --update-golden mask that.
|
||||
ok = print_report(all_results)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -67,8 +67,9 @@ def main():
|
||||
print(" 📍 Will run on: http://0.0.0.0:5000")
|
||||
print(" ⏹️ Press Ctrl+C to stop")
|
||||
|
||||
# Run the app (this should start the server)
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
# Run the app (debug mode controlled by env var to satisfy security scanners)
|
||||
_debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1'
|
||||
app.run(host='0.0.0.0', port=5000, debug=_debug)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n ⏹️ Server stopped by user")
|
||||
|
||||
@@ -17,7 +17,6 @@ import os
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence, Union
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -28,49 +27,15 @@ os.environ['EMULATOR'] = 'true'
|
||||
|
||||
# Import logger after path setup so src.logging_config is importable
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
find_plugin_dir, load_manifest, load_config_defaults,
|
||||
)
|
||||
logger = get_logger("[Render Plugin]")
|
||||
|
||||
MIN_DIMENSION = 1
|
||||
MAX_DIMENSION = 512
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
||||
"""Find a plugin directory by searching multiple paths."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in search_dirs:
|
||||
search_path = Path(search_dir)
|
||||
if not search_path.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_path)
|
||||
if result:
|
||||
return Path(result)
|
||||
return None
|
||||
|
||||
|
||||
def load_manifest(plugin_dir: Path) -> Dict[str, Any]:
|
||||
"""Load and return manifest.json from plugin directory."""
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: Path) -> Dict[str, Any]:
|
||||
"""Extract default values from config_schema.json."""
|
||||
schema_path = plugin_dir / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in schema.get('properties', {}).items():
|
||||
if 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
||||
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
||||
|
||||
@@ -410,8 +410,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
try:
|
||||
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||
manifest = json.loads(manifest_raw)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||
return False, f"Invalid manifest.json: {e}", {}
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False, "Invalid manifest.json", {}
|
||||
|
||||
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
||||
return False, "Invalid manifest structure", {}
|
||||
@@ -456,8 +456,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
return True, "", result_manifest
|
||||
except zipfile.BadZipFile:
|
||||
return False, "File is not a valid ZIP archive", {}
|
||||
except OSError as e:
|
||||
return False, f"Could not read backup: {e}", {}
|
||||
except OSError:
|
||||
return False, "Could not read backup", {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
"""
|
||||
Cache Manager — multi-tier response cache for the LEDMatrix application.
|
||||
|
||||
:class:`CacheManager` provides a unified caching layer used by all plugins
|
||||
to reduce external API calls and survive network outages gracefully.
|
||||
|
||||
Two storage tiers
|
||||
-----------------
|
||||
* **Memory tier** (:class:`~src.cache.memory_cache.MemoryCache`): fast LRU
|
||||
cache (up to 1 000 entries by default). Hit on this tier before touching
|
||||
disk.
|
||||
* **Disk tier** (:class:`~src.cache.disk_cache.DiskCache`): filesystem-backed
|
||||
persistent store that survives process restarts.
|
||||
|
||||
Data written to cache is serialised as JSON. :class:`DateTimeEncoder` handles
|
||||
``datetime`` objects transparently so callers don't have to pre-serialise them.
|
||||
|
||||
Typical plugin usage::
|
||||
|
||||
data = self.cache_manager.get_cached_data('my_key', max_age=300)
|
||||
if data is None:
|
||||
data = fetch_from_api()
|
||||
self.cache_manager.save_cache('my_key', data)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
@@ -15,7 +40,10 @@ from src.cache.cache_metrics import CacheMetrics
|
||||
from src.logging_config import get_logger
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
"""JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
|
||||
|
||||
def default(self, obj):
|
||||
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return super().default(obj)
|
||||
|
||||
@@ -347,34 +347,40 @@ class ScrollHelper:
|
||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||
|
||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||
"""Fast integer pixel extraction (no interpolation)."""
|
||||
# Fast numpy array slicing for normal case (no wrap-around)
|
||||
if end_x <= self.cached_image.width:
|
||||
# Normal case: single slice - fastest path
|
||||
frame_array = self.cached_array[:, start_x:end_x]
|
||||
# Convert to PIL Image (minimal overhead)
|
||||
return Image.fromarray(frame_array)
|
||||
"""Fast integer pixel extraction (no interpolation).
|
||||
|
||||
Uses Image.frombytes instead of Image.fromarray: frombytes skips
|
||||
numpy's array-protocol overhead and is ~50% faster for the display-sized
|
||||
slices (128×32 = 12 KB) used here.
|
||||
"""
|
||||
_size = (self.display_width, self.display_height)
|
||||
img_w = self.cached_image.width
|
||||
|
||||
if end_x <= img_w:
|
||||
# Normal case: single contiguous slice (fastest path)
|
||||
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
|
||||
return Image.frombytes('RGB', _size, frame_array.tobytes())
|
||||
else:
|
||||
# Wrap-around case: combine two slices using numpy
|
||||
width1 = self.cached_image.width - start_x
|
||||
# Ensure frame buffer is allocated for all non-simple paths
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
|
||||
width1 = img_w - start_x
|
||||
if width1 > 0:
|
||||
# Use pre-allocated buffer for output
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
|
||||
# First part from end of image (fast numpy slice)
|
||||
# Wrap-around: tail of image + head of image
|
||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
||||
|
||||
# Second part from beginning of image
|
||||
remaining_width = self.display_width - width1
|
||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
||||
|
||||
# Convert combined buffer to PIL Image
|
||||
return Image.fromarray(self._frame_buffer)
|
||||
else:
|
||||
# Edge case: start_x >= image width, wrap to beginning
|
||||
frame_array = self.cached_array[:, :self.display_width]
|
||||
return Image.fromarray(frame_array)
|
||||
# Edge case: start_x at or past image end — show from beginning,
|
||||
# clamped to available width (scroll_position should wrap before
|
||||
# reaching this state in normal operation).
|
||||
available = min(self.display_width, img_w)
|
||||
self._frame_buffer[:, :available] = self.cached_array[:, :available]
|
||||
if available < self.display_width:
|
||||
self._frame_buffer[:, available:] = 0
|
||||
|
||||
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||
|
||||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
"""
|
||||
Config Manager — reads, writes, and validates ``config/config.json``.
|
||||
|
||||
:class:`ConfigManager` is the single owner of the on-disk configuration
|
||||
files:
|
||||
|
||||
* ``config/config.json`` — main user-editable configuration.
|
||||
* ``config/config_secrets.json`` — sensitive values (API keys, tokens).
|
||||
|
||||
All writes go through :class:`~src.config_manager_atomic.AtomicConfigManager`
|
||||
which performs a backup before overwriting, validates the result, and rolls
|
||||
back on error. This makes config corruption essentially impossible.
|
||||
|
||||
Plugin configuration
|
||||
--------------------
|
||||
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
||||
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
|
||||
to write plugin settings; never write directly to the plugin directory.
|
||||
|
||||
Hot-reload
|
||||
----------
|
||||
:class:`~src.config_service.ConfigService` wraps ``ConfigManager`` and
|
||||
detects file changes, broadcasting the new config to registered listeners
|
||||
without requiring a restart.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
@@ -17,6 +43,13 @@ from src.common.permission_utils import (
|
||||
)
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
Reads and writes the main application configuration files.
|
||||
|
||||
Wraps :class:`~src.config_manager_atomic.AtomicConfigManager` for safe
|
||||
atomic writes with automatic backup and rollback. Also exposes helpers
|
||||
for plugin configuration persistence and secret-field masking.
|
||||
"""
|
||||
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
|
||||
# Use current working directory as base
|
||||
self.config_path: str = config_path or "config/config.json"
|
||||
@@ -29,9 +62,11 @@ class ConfigManager:
|
||||
self._atomic_manager: Optional[AtomicConfigManager] = None
|
||||
|
||||
def get_config_path(self) -> str:
|
||||
"""Return the path to the main config file (``config/config.json``)."""
|
||||
return self.config_path
|
||||
|
||||
def get_secrets_path(self) -> str:
|
||||
"""Return the path to the secrets file (``config/config_secrets.json``)."""
|
||||
return self.secrets_path
|
||||
|
||||
def _get_atomic_manager(self) -> AtomicConfigManager:
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
"""
|
||||
Display Controller — top-level orchestration for the LEDMatrix application.
|
||||
|
||||
This module owns the main run loop that drives the LED display. It ties
|
||||
together every major subsystem:
|
||||
|
||||
- ConfigManager / ConfigService — loads config.json, hot-reloads on change
|
||||
- DisplayManager — hardware (or emulator) output interface
|
||||
- FontManager — TTF/BDF font loading and caching
|
||||
- CacheManager — multi-tier API response cache
|
||||
- PluginManager — plugin lifecycle (load, update, display)
|
||||
- DisplaySyncManager — optional leader/follower multi-Pi sync
|
||||
- VegasModeCoordinator — optional continuous Vegas scroll mode
|
||||
|
||||
The main loop inside :meth:`DisplayController.run` rotates through enabled
|
||||
plugin display modes, respecting schedule windows, brightness dim schedules,
|
||||
on-demand overrides, and live-priority interrupts.
|
||||
|
||||
Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
||||
:meth:`~DisplayController.run`.
|
||||
"""
|
||||
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
@@ -28,6 +50,24 @@ DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
||||
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
||||
|
||||
class DisplayController:
|
||||
"""
|
||||
Top-level controller that owns the LED display run loop.
|
||||
|
||||
Responsibilities
|
||||
----------------
|
||||
* Initialise and wire together all subsystems at startup.
|
||||
* Rotate through plugin display modes in :meth:`run`.
|
||||
* Honour schedule windows (active/inactive hours) and dim schedules.
|
||||
* Handle on-demand override requests (external callers can pin a
|
||||
specific plugin/mode for a fixed duration via the cache bus).
|
||||
* Coordinate with a follower Pi when multi-display sync is configured.
|
||||
* Delegate all actual content to the plugin system — this class contains
|
||||
no display logic of its own.
|
||||
|
||||
There is exactly one instance per process; call :func:`main` to create
|
||||
it and start the run loop.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
start_time = time.time()
|
||||
logger.info("Starting DisplayController initialization")
|
||||
@@ -138,7 +178,11 @@ class DisplayController:
|
||||
self.on_demand_last_event: Optional[str] = None
|
||||
self.on_demand_schedule_override = False
|
||||
self.rotation_resume_index: Optional[int] = None
|
||||
|
||||
# Saved rotation position when a live-priority plugin preempts the
|
||||
# rotation, so it resumes where it left off (not after the live plugin)
|
||||
# once live priority ends.
|
||||
self._live_resume_index: Optional[int] = None
|
||||
|
||||
# WiFi status message tracking
|
||||
global WIFI_STATUS_FILE
|
||||
if WIFI_STATUS_FILE is None:
|
||||
@@ -148,7 +192,11 @@ class DisplayController:
|
||||
self.wifi_status_file = WIFI_STATUS_FILE
|
||||
self.wifi_status_active = False
|
||||
self.wifi_status_expires_at: Optional[float] = None
|
||||
|
||||
|
||||
# Plugin display() signature cache — must be initialised before the plugin
|
||||
# loading loop below so the .pop() invalidation at load time is always safe.
|
||||
self._plugin_accepts_display_mode: Dict[str, bool] = {}
|
||||
|
||||
try:
|
||||
logger.info("Attempting to import plugin system...")
|
||||
from src.plugin_system import PluginManager
|
||||
@@ -321,6 +369,8 @@ class DisplayController:
|
||||
self.plugin_modes[mode] = plugin_instance
|
||||
self.mode_to_plugin_id[mode] = plugin_id
|
||||
logger.debug(" Added mode: %s", mode)
|
||||
# Invalidate signature cache so the new instance is re-inspected
|
||||
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||
|
||||
# Show progress
|
||||
progress_pct = int((loaded_count / enabled_count) * 100)
|
||||
@@ -367,11 +417,39 @@ class DisplayController:
|
||||
self.is_display_active = True
|
||||
self._was_display_active = True # Track previous state for schedule change detection
|
||||
|
||||
# --- Opt #2: cached config values ---
|
||||
# Avoids chained dict.get() with temporary {} defaults on every hot path call.
|
||||
# Refreshed via _refresh_config_cache() on every hot-reload.
|
||||
self._normal_brightness: int = (
|
||||
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
)
|
||||
self._scroll_speed: float = (
|
||||
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||
)
|
||||
|
||||
# Brightness state tracking for dim schedule
|
||||
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
self.current_brightness = self._normal_brightness
|
||||
self.is_dimmed = False
|
||||
self._was_dimmed = False
|
||||
|
||||
# --- Opt #3: schedule minute-gate ---
|
||||
# Both _check_schedule and _check_dim_schedule re-evaluated at most once per
|
||||
# clock minute. Storing the (hour, minute) tuple that was last evaluated lets
|
||||
# the methods skip all timezone / strptime work within the same minute.
|
||||
# Reset to None on config change so the next call re-evaluates immediately.
|
||||
self._tz = None # pytz timezone, lazily built from config
|
||||
self._schedule_checked_minute: Optional[tuple] = None
|
||||
self._dim_checked_minute: Optional[tuple] = None
|
||||
self._cached_target_brightness: int = self._normal_brightness
|
||||
|
||||
# Register controller-level hot-reload callback so cached config values
|
||||
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
||||
# when the user saves settings via the web UI.
|
||||
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||
self._refresh_config_cache(new_config)
|
||||
|
||||
self.config_service.subscribe(_controller_config_change)
|
||||
|
||||
# Publish initial on-demand state
|
||||
try:
|
||||
self._publish_on_demand_state()
|
||||
@@ -533,17 +611,24 @@ class DisplayController:
|
||||
logger.debug("Schedule is disabled - display always active")
|
||||
return
|
||||
|
||||
# Get configured timezone, default to UTC
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
|
||||
tz = pytz.UTC
|
||||
# Lazily build the timezone object once; reuse on every subsequent call.
|
||||
if self._tz is None:
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
self._tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning("Unknown timezone '%s', using UTC", timezone_str)
|
||||
self._tz = pytz.UTC
|
||||
|
||||
# Use timezone-aware current time
|
||||
current_time = datetime.now(tz)
|
||||
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
|
||||
current_time = datetime.now(self._tz)
|
||||
# Gate: schedule state can only change on a minute boundary, so skip
|
||||
# all the strptime / comparison work if we already evaluated this minute.
|
||||
current_minute_key = (current_time.hour, current_time.minute)
|
||||
if current_minute_key == self._schedule_checked_minute:
|
||||
return
|
||||
self._schedule_checked_minute = current_minute_key
|
||||
|
||||
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
||||
current_time_only = current_time.time()
|
||||
|
||||
# Check if per-day schedule is configured
|
||||
@@ -632,8 +717,8 @@ class DisplayController:
|
||||
Target brightness level (dim_brightness if in dim period,
|
||||
normal brightness otherwise)
|
||||
"""
|
||||
# Get normal brightness from config
|
||||
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
# Opt #2: use cached brightness rather than re-traversing config dict
|
||||
normal_brightness = self._normal_brightness
|
||||
|
||||
# If display is OFF via schedule, don't process dim schedule
|
||||
if not self.is_display_active:
|
||||
@@ -647,15 +732,21 @@ class DisplayController:
|
||||
self.is_dimmed = False
|
||||
return normal_brightness
|
||||
|
||||
# Get configured timezone
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
|
||||
tz = pytz.UTC
|
||||
# Opt #3: lazily build timezone; gate full re-parse to once per clock minute
|
||||
if self._tz is None:
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
self._tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning("Unknown timezone '%s' in dim schedule, using UTC", timezone_str)
|
||||
self._tz = pytz.UTC
|
||||
|
||||
current_time = datetime.now(self._tz)
|
||||
current_minute_key = (current_time.hour, current_time.minute)
|
||||
if current_minute_key == self._dim_checked_minute:
|
||||
return self._cached_target_brightness
|
||||
self._dim_checked_minute = current_minute_key
|
||||
|
||||
current_time = datetime.now(tz)
|
||||
current_day = current_time.strftime('%A').lower()
|
||||
current_time_only = current_time.time()
|
||||
|
||||
@@ -703,10 +794,12 @@ class DisplayController:
|
||||
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
||||
|
||||
self._was_dimmed = self.is_dimmed
|
||||
self._cached_target_brightness = target_brightness # persist for minute-gate
|
||||
return target_brightness
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid dim schedule time format: {e}")
|
||||
logger.warning("Invalid dim schedule time format: %s", e)
|
||||
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||
return normal_brightness
|
||||
|
||||
def _update_modules(self):
|
||||
@@ -1382,6 +1475,36 @@ class DisplayController:
|
||||
except Exception as e:
|
||||
logger.debug(f"Error logging memory stats: {e}")
|
||||
|
||||
def _apply_live_priority(self, live_priority_mode):
|
||||
"""Switch to a live-priority mode, or resume rotation when it ends.
|
||||
|
||||
When a live-priority plugin preempts the rotation, the position the
|
||||
rotation had reached is saved so that, once live priority ends, the
|
||||
rotation resumes from there instead of continuing after the live
|
||||
plugin's mode (which would skip every mode between the two). The save
|
||||
happens only on the initial switch, not on each re-check while the
|
||||
live hold continues.
|
||||
"""
|
||||
if live_priority_mode:
|
||||
if self.current_display_mode != live_priority_mode:
|
||||
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
|
||||
if self._live_resume_index is None:
|
||||
self._live_resume_index = self.current_mode_index
|
||||
self.current_display_mode = live_priority_mode
|
||||
self.force_change = True
|
||||
# Update mode index to match the new mode
|
||||
try:
|
||||
self.current_mode_index = self.available_modes.index(live_priority_mode)
|
||||
except ValueError:
|
||||
pass
|
||||
elif self._live_resume_index is not None and self.available_modes:
|
||||
# Live priority ended — resume rotation where it was interrupted.
|
||||
self.current_mode_index = self._live_resume_index % len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
self.force_change = True
|
||||
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||
self._live_resume_index = None
|
||||
|
||||
def _check_live_priority(self):
|
||||
"""
|
||||
Check all plugins for live priority content.
|
||||
@@ -1483,12 +1606,8 @@ class DisplayController:
|
||||
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
||||
width = self.display_manager.width
|
||||
|
||||
# Advance local position at Vegas scroll speed (px/s → px/tick)
|
||||
vegas_speed = (
|
||||
self.config.get('display', {})
|
||||
.get('vegas_scroll', {})
|
||||
.get('scroll_speed', 75)
|
||||
)
|
||||
# Opt #2: use pre-cached scroll speed (constant for the run)
|
||||
vegas_speed = self._scroll_speed
|
||||
local_x = getattr(self, '_follower_local_x', None)
|
||||
if local_x is None:
|
||||
local_x = float(width) # safe start (past pre-roll guard)
|
||||
@@ -1573,15 +1692,7 @@ class DisplayController:
|
||||
# Check for live priority content and switch to it immediately
|
||||
if not self.on_demand_active and not wifi_status_data:
|
||||
live_priority_mode = self._check_live_priority()
|
||||
if live_priority_mode and self.current_display_mode != live_priority_mode:
|
||||
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
|
||||
self.current_display_mode = live_priority_mode
|
||||
self.force_change = True
|
||||
# Update mode index to match the new mode
|
||||
try:
|
||||
self.current_mode_index = self.available_modes.index(live_priority_mode)
|
||||
except ValueError:
|
||||
pass
|
||||
self._apply_live_priority(live_priority_mode)
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||
@@ -1628,7 +1739,8 @@ class DisplayController:
|
||||
|
||||
manager_to_display = None
|
||||
|
||||
logger.info(f"Processing mode: {active_mode}, available_modes: {len(self.available_modes)}, plugin_modes: {list(self.plugin_modes.keys())}")
|
||||
logger.info("Processing mode: %s (%d available)", active_mode, len(self.available_modes))
|
||||
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
||||
|
||||
# Handle plugin-based display modes
|
||||
if active_mode in self.plugin_modes:
|
||||
@@ -1664,17 +1776,22 @@ class DisplayController:
|
||||
try:
|
||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||
if hasattr(manager_to_display, 'display'):
|
||||
# Check if plugin accepts display_mode parameter
|
||||
import inspect
|
||||
sig = inspect.signature(manager_to_display.display)
|
||||
|
||||
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||
_cache_key = plugin_id
|
||||
if _cache_key not in self._plugin_accepts_display_mode:
|
||||
import inspect as _inspect
|
||||
self._plugin_accepts_display_mode[_cache_key] = (
|
||||
'display_mode' in _inspect.signature(manager_to_display.display).parameters
|
||||
)
|
||||
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
||||
|
||||
# Use PluginExecutor for safe execution with timeout
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
||||
result = self.plugin_manager.plugin_executor.execute_display(
|
||||
manager_to_display,
|
||||
plugin_id,
|
||||
force_clear=self.force_change,
|
||||
display_mode=active_mode if 'display_mode' in sig.parameters else None
|
||||
display_mode=active_mode if _accepts_display_mode else None
|
||||
)
|
||||
# execute_display returns bool, convert to expected format
|
||||
if result:
|
||||
@@ -1683,7 +1800,7 @@ class DisplayController:
|
||||
result = False # Failed
|
||||
else:
|
||||
# Fallback to direct call if executor not available
|
||||
if 'display_mode' in sig.parameters:
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=self.force_change)
|
||||
@@ -1820,9 +1937,9 @@ class DisplayController:
|
||||
min_duration = base_duration
|
||||
if dynamic_enabled:
|
||||
# Try to get plugin-calculated cycle duration first
|
||||
logger.info("Attempting to get cycle duration for mode %s", active_mode)
|
||||
logger.debug("Attempting to get cycle duration for mode %s", active_mode)
|
||||
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
|
||||
logger.info("Got cycle duration: %s", plugin_cycle_duration)
|
||||
logger.debug("Got cycle duration: %s", plugin_cycle_duration)
|
||||
|
||||
# Get caps for validation
|
||||
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
||||
@@ -1962,7 +2079,7 @@ class DisplayController:
|
||||
if needs_high_fps:
|
||||
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
||||
display_interval = 0.008
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
||||
active_mode,
|
||||
display_interval,
|
||||
@@ -1972,7 +2089,7 @@ class DisplayController:
|
||||
while True:
|
||||
try:
|
||||
# Pass display_mode to maintain sticky manager state
|
||||
if 'display_mode' in sig.parameters:
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
@@ -2014,7 +2131,7 @@ class DisplayController:
|
||||
else:
|
||||
# Normal FPS for other plugins (1 second)
|
||||
display_interval = 1.0
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
||||
active_mode,
|
||||
display_interval
|
||||
@@ -2036,7 +2153,7 @@ class DisplayController:
|
||||
|
||||
try:
|
||||
# Pass display_mode to maintain sticky manager state
|
||||
if 'display_mode' in sig.parameters:
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
@@ -2333,6 +2450,30 @@ class DisplayController:
|
||||
self.wifi_status_active = False
|
||||
self.wifi_status_expires_at = None
|
||||
|
||||
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
|
||||
"""Refresh all config-derived caches when a hot-reload fires.
|
||||
|
||||
Called by the controller-level ConfigService subscriber. Keeps
|
||||
``_normal_brightness``, ``_scroll_speed``, the cached timezone, and the
|
||||
schedule minute-gates consistent with the live config so callers never
|
||||
read stale values after the user saves settings via the web UI.
|
||||
"""
|
||||
self.config = new_config
|
||||
self._normal_brightness = (
|
||||
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
)
|
||||
self._scroll_speed = (
|
||||
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||
)
|
||||
# Force the timezone to be re-derived from the new config on next schedule check
|
||||
self._tz = None
|
||||
# Invalidate minute-gates so the new schedule/dim times take effect immediately
|
||||
self._schedule_checked_minute = None
|
||||
self._dim_checked_minute = None
|
||||
self._cached_target_brightness = self._normal_brightness
|
||||
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
|
||||
self._normal_brightness, self._scroll_speed)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
# Shutdown config service if it exists
|
||||
@@ -2347,6 +2488,7 @@ class DisplayController:
|
||||
logger.info("Cleanup complete.")
|
||||
|
||||
def main():
|
||||
"""Application entry point — create a DisplayController and run until interrupted."""
|
||||
controller = DisplayController()
|
||||
controller.run()
|
||||
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
"""
|
||||
Display Manager — hardware abstraction layer for the RGB LED matrix.
|
||||
|
||||
This module provides :class:`DisplayManager`, the single interface between
|
||||
application code and the physical (or emulated) LED panel.
|
||||
|
||||
Key responsibilities
|
||||
--------------------
|
||||
* Initialise the ``RGBMatrix`` (hardware) or ``RGBMatrixEmulator`` depending
|
||||
on the ``EMULATOR`` environment variable.
|
||||
* Expose a PIL ``Image``/``ImageDraw`` canvas that plugins draw into, then
|
||||
flush it to the matrix via double-buffering (:meth:`DisplayManager.update_display`).
|
||||
* Load and cache TTF/BDF fonts; expose ``draw_text`` for consistent text rendering.
|
||||
* Provide ``width`` / ``height`` properties — always use these instead of
|
||||
hard-coding display dimensions.
|
||||
* Write periodic PNG snapshots to ``/tmp/led_matrix_preview.png`` for the
|
||||
web-interface live preview.
|
||||
* Track scrolling state and gate deferred updates so plugins don't race with
|
||||
an in-progress scroll.
|
||||
|
||||
Singleton: only one ``DisplayManager`` instance exists per process. The
|
||||
first call to ``DisplayManager(config)`` creates it; subsequent calls return
|
||||
the same object.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
@@ -18,6 +43,24 @@ logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO) # Set to INFO level
|
||||
|
||||
class DisplayManager:
|
||||
"""
|
||||
Singleton hardware abstraction layer for the RGB LED matrix.
|
||||
|
||||
Plugins should never interact with ``RGBMatrix`` directly; they use this
|
||||
class to draw content and call :meth:`update_display` to push frames to
|
||||
the panel.
|
||||
|
||||
Typical plugin usage::
|
||||
|
||||
canvas = Image.new('RGB', (self.display_manager.width,
|
||||
self.display_manager.height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
# ... draw content ...
|
||||
self.display_manager.image = canvas
|
||||
self.display_manager.draw = ImageDraw.Draw(self.display_manager.image)
|
||||
self.display_manager.update_display()
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
@@ -33,6 +76,10 @@ class DisplayManager:
|
||||
self._suppress_test_pattern = suppress_test_pattern
|
||||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||
self._capture_mode_active = False
|
||||
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
||||
# Avoids re-measuring the same string+font on every display() call.
|
||||
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||
self._text_width_cache: Dict[tuple, int] = {}
|
||||
# Snapshot settings for web preview integration (service writes, web reads)
|
||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
||||
@@ -190,7 +237,7 @@ class DisplayManager:
|
||||
json.dump(_hw_status, _f)
|
||||
_f.flush()
|
||||
os.fsync(_f.fileno())
|
||||
os.chmod(_tmp_path, 0o600)
|
||||
os.chmod(_tmp_path, 0o644)
|
||||
os.replace(_tmp_path, _status_path)
|
||||
except Exception:
|
||||
try:
|
||||
@@ -437,6 +484,9 @@ class DisplayManager:
|
||||
|
||||
def _load_fonts(self):
|
||||
"""Load fonts with proper error handling."""
|
||||
# Font objects get new id()s after reload, so the text-width cache would
|
||||
# return stale measurements keyed on the old ids. Clear it here.
|
||||
self._text_width_cache.clear()
|
||||
try:
|
||||
# Load Press Start 2P font
|
||||
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
@@ -497,22 +547,32 @@ class DisplayManager:
|
||||
|
||||
|
||||
def get_text_width(self, text, font):
|
||||
"""Get the width of text when rendered with the given font."""
|
||||
"""Get the width of text when rendered with the given font.
|
||||
|
||||
Results are cached by (text, font identity) so plugins that measure
|
||||
the same string every frame (e.g. to centre a score) pay only one
|
||||
measurement per unique (text, font) pair.
|
||||
"""
|
||||
cache_key = (text, id(font))
|
||||
cached = self._text_width_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
if isinstance(font, freetype.Face):
|
||||
# For FreeType faces, calculate width using freetype
|
||||
width = 0
|
||||
for char in text:
|
||||
font.load_char(char)
|
||||
width += font.glyph.advance.x >> 6
|
||||
return width
|
||||
else:
|
||||
# For PIL fonts, use textbbox
|
||||
bbox = self.draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting text width: {e}")
|
||||
return 0 # Return 0 as fallback
|
||||
width = bbox[2] - bbox[0]
|
||||
except (AttributeError, TypeError, ValueError, OSError) as e:
|
||||
logger.error("Error getting text width: %s", e)
|
||||
return 0
|
||||
|
||||
self._text_width_cache[cache_key] = width
|
||||
return width
|
||||
|
||||
def get_font_height(self, font):
|
||||
"""Get the height of the given font for line spacing purposes."""
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
"""
|
||||
Font Manager — TTF/BDF font loading, caching, and dynamic registration.
|
||||
|
||||
:class:`FontManager` serves two purposes:
|
||||
|
||||
1. **System fonts** — loads the configured small/medium/large TTF fonts (and
|
||||
their BDF bitmap equivalents) at startup, caches metrics, and exposes them
|
||||
via ``DisplayManager`` attributes (``small_font``, ``medium_font``, etc.).
|
||||
|
||||
2. **Plugin fonts** — lets plugins register their own fonts at runtime via
|
||||
:meth:`FontManager.register_manager_font` and resolve them later via
|
||||
:meth:`FontManager.resolve_font`. Registered fonts are namespaced by
|
||||
plugin ID so they cannot collide.
|
||||
|
||||
Font sources
|
||||
------------
|
||||
* Local paths relative to the project root.
|
||||
* Remote URLs — downloaded once, cached to disk, and never re-fetched while
|
||||
the cached copy is fresh.
|
||||
|
||||
BDF fallback
|
||||
------------
|
||||
Pixel-accurate LED fonts are stored as ``.bdf`` (Bitmap Distribution Format)
|
||||
files. When PIL cannot measure BDF glyphs natively, ``freetype-py`` is used
|
||||
for accurate width/height calculations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import freetype
|
||||
|
||||
@@ -5,9 +5,11 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
||||
Extracted from PluginManager to improve separation of concerns.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import importlib
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -68,6 +70,11 @@ class PluginLoader:
|
||||
Returns:
|
||||
Path to plugin directory or None if not found
|
||||
"""
|
||||
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
return None
|
||||
|
||||
# Strategy 1: Use mapping from discovery
|
||||
if plugin_directories and plugin_id in plugin_directories:
|
||||
plugin_dir = plugin_directories[plugin_id]
|
||||
@@ -75,14 +82,16 @@ class PluginLoader:
|
||||
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
||||
return plugin_dir
|
||||
|
||||
# Strategy 2: Direct paths
|
||||
plugin_dir = plugins_dir / plugin_id
|
||||
if plugin_dir.exists():
|
||||
return plugin_dir
|
||||
|
||||
plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}"
|
||||
if plugin_dir.exists():
|
||||
return plugin_dir
|
||||
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
|
||||
plugins_dir_resolved = plugins_dir.resolve()
|
||||
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
|
||||
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
|
||||
try:
|
||||
_candidate.relative_to(plugins_dir_resolved)
|
||||
except ValueError:
|
||||
continue
|
||||
if _candidate.exists():
|
||||
return _candidate
|
||||
|
||||
# Strategy 3: Case-insensitive search
|
||||
normalized_id = plugin_id.lower()
|
||||
@@ -130,51 +139,123 @@ class PluginLoader:
|
||||
self,
|
||||
plugin_dir: Path,
|
||||
plugin_id: str,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
timeout: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
Install plugin dependencies from requirements.txt.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_dir: Plugin directory path
|
||||
plugin_id: Plugin identifier
|
||||
plugins_dir: Trusted base plugins directory for path containment check
|
||||
timeout: Installation timeout in seconds
|
||||
|
||||
|
||||
Returns:
|
||||
True if dependencies installed or not needed, False on error
|
||||
"""
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
if not requirements_file.exists():
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
return False
|
||||
|
||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||
|
||||
if plugins_dir is not None:
|
||||
# Reconstruct the plugin path from a trusted base + a sanitised
|
||||
# directory name. os.path.basename() is CodeQL's recognised
|
||||
# py/path-injection sanitiser: it strips all directory components
|
||||
# so the result cannot contain traversal sequences. Joining it
|
||||
# with the resolved, trusted plugins_dir produces a path that
|
||||
# CodeQL considers untainted.
|
||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||
safe_dir_name = os.path.basename(plugin_dir_real)
|
||||
if not safe_dir_name:
|
||||
self.logger.error("Could not determine plugin directory name for %s", plugin_id)
|
||||
return False
|
||||
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name)
|
||||
if not os.path.isdir(safe_plugin_dir):
|
||||
self.logger.error(
|
||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||
)
|
||||
return False
|
||||
else:
|
||||
safe_plugin_dir = plugin_dir_real
|
||||
if not os.path.isdir(safe_plugin_dir):
|
||||
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
||||
return False
|
||||
|
||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
||||
marker_file = os.path.join(safe_plugin_dir, ".dependencies_installed")
|
||||
|
||||
if not os.path.isfile(requirements_file):
|
||||
return True # No dependencies needed
|
||||
|
||||
# Check if already installed
|
||||
marker_path = plugin_dir / ".dependencies_installed"
|
||||
if marker_path.exists():
|
||||
self.logger.debug("Dependencies already installed for %s", plugin_id)
|
||||
return True
|
||||
|
||||
|
||||
try:
|
||||
with open(requirements_file, 'rb') as fh:
|
||||
current_hash = hashlib.sha256(fh.read()).hexdigest()
|
||||
except OSError as e:
|
||||
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
|
||||
return False
|
||||
|
||||
# Skip if requirements.txt hasn't changed since last install
|
||||
if os.path.isfile(marker_file):
|
||||
try:
|
||||
with open(marker_file, 'r', encoding='utf-8') as fh:
|
||||
stored_hash = fh.read().strip()
|
||||
except OSError as e:
|
||||
self.logger.warning(
|
||||
"Could not read dependency marker for %s (%s), will reinstall dependencies",
|
||||
plugin_id, e
|
||||
)
|
||||
else:
|
||||
if stored_hash == current_hash:
|
||||
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
|
||||
return True
|
||||
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
|
||||
|
||||
try:
|
||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
|
||||
|
||||
if result.returncode == 0:
|
||||
# Mark as installed
|
||||
marker_path.touch()
|
||||
# Set proper file permissions after creating marker
|
||||
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||
try:
|
||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
||||
fh.write(current_hash)
|
||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||
return True
|
||||
else:
|
||||
stderr = result.stderr or ""
|
||||
# uninstall-no-record-file means the package is already present at the
|
||||
# system level (e.g. installed via dnf/apt without a pip RECORD file).
|
||||
# pip can't replace it, but it IS installed — write the marker so we
|
||||
# don't retry on every restart.
|
||||
if "uninstall-no-record-file" in stderr:
|
||||
self.logger.warning(
|
||||
"Dependencies for %s include system-managed packages (no pip RECORD). "
|
||||
"Assuming they are satisfied: %s",
|
||||
plugin_id, stderr.strip()
|
||||
)
|
||||
try:
|
||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
||||
fh.write(current_hash)
|
||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
||||
return True
|
||||
self.logger.warning(
|
||||
"Dependency installation returned non-zero exit code for %s: %s",
|
||||
plugin_id,
|
||||
result.stderr
|
||||
stderr
|
||||
)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -349,9 +430,20 @@ class PluginLoader:
|
||||
Returns:
|
||||
Loaded module or None on error
|
||||
"""
|
||||
entry_file = plugin_dir / entry_point
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
raise PluginError("Invalid plugin ID")
|
||||
try:
|
||||
plugin_dir_resolved = plugin_dir.resolve(strict=True)
|
||||
except OSError:
|
||||
raise PluginError("Plugin directory not found", plugin_id=plugin_id)
|
||||
entry_file = (plugin_dir_resolved / entry_point).resolve()
|
||||
try:
|
||||
entry_file.relative_to(plugin_dir_resolved)
|
||||
except ValueError:
|
||||
raise PluginError("Invalid entry point path", plugin_id=plugin_id)
|
||||
if not entry_file.exists():
|
||||
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
|
||||
error_msg = f"Entry point file not found for plugin {plugin_id}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
@@ -501,11 +593,12 @@ class PluginLoader:
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
install_deps: bool = True
|
||||
install_deps: bool = True,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
) -> Tuple[Any, Any]:
|
||||
"""
|
||||
Complete plugin loading process.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
manifest: Plugin manifest
|
||||
@@ -515,16 +608,22 @@ class PluginLoader:
|
||||
cache_manager: Cache manager instance
|
||||
plugin_manager: Plugin manager instance
|
||||
install_deps: Whether to install dependencies
|
||||
|
||||
plugins_dir: Trusted base plugins directory forwarded to install_dependencies
|
||||
|
||||
Returns:
|
||||
Tuple of (plugin_instance, module)
|
||||
|
||||
|
||||
Raises:
|
||||
PluginError: If loading fails
|
||||
"""
|
||||
# Install dependencies if needed
|
||||
if install_deps:
|
||||
self.install_dependencies(plugin_dir, plugin_id)
|
||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
||||
raise PluginError(
|
||||
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
||||
plugin_id=plugin_id,
|
||||
context={'plugin_dir': str(plugin_dir)},
|
||||
)
|
||||
|
||||
# Load module
|
||||
entry_point = manifest.get('entry_point', 'manager.py')
|
||||
|
||||
@@ -15,7 +15,7 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
import logging
|
||||
from src.exceptions import PluginError
|
||||
from src.exceptions import PluginError, ConfigError
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
@@ -81,7 +81,13 @@ class PluginManager:
|
||||
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
||||
self.plugin_modules: Dict[str, Any] = {}
|
||||
self.plugin_last_update: Dict[str, float] = {}
|
||||
|
||||
|
||||
# Cached data-fetch intervals per plugin_id.
|
||||
# _get_plugin_update_interval falls back to config_manager.get_config()
|
||||
# (a full dict copy) when the manifest lacks an interval — caching avoids
|
||||
# that copy on every 30-fps tick. Cleared on load/unload.
|
||||
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
||||
|
||||
# Health tracking (optional, set by display_controller if available)
|
||||
self.health_tracker = None
|
||||
self.resource_monitor = None
|
||||
@@ -350,7 +356,8 @@ class PluginManager:
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self,
|
||||
install_deps=True
|
||||
install_deps=True,
|
||||
plugins_dir=self.plugins_dir,
|
||||
)
|
||||
|
||||
# Store module
|
||||
@@ -387,6 +394,8 @@ class PluginManager:
|
||||
# Store plugin instance
|
||||
self.plugins[plugin_id] = plugin_instance
|
||||
self.plugin_last_update[plugin_id] = 0.0
|
||||
# Invalidate cached interval so next tick re-derives it for this plugin
|
||||
self._update_interval_cache.pop(plugin_id, None)
|
||||
|
||||
# Update state based on enabled status
|
||||
if config.get('enabled', True):
|
||||
@@ -443,8 +452,8 @@ class PluginManager:
|
||||
|
||||
# Remove from active plugins
|
||||
del self.plugins[plugin_id]
|
||||
if plugin_id in self.plugin_last_update:
|
||||
del self.plugin_last_update[plugin_id]
|
||||
self.plugin_last_update.pop(plugin_id, None)
|
||||
self._update_interval_cache.pop(plugin_id, None)
|
||||
|
||||
# Remove main module from sys.modules if present
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
@@ -638,41 +647,46 @@ class PluginManager:
|
||||
|
||||
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
||||
"""
|
||||
Get the update interval for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
plugin_instance: Plugin instance
|
||||
|
||||
Returns:
|
||||
Update interval in seconds or None if not configured
|
||||
Get the data-fetch interval for a plugin (seconds between update() calls).
|
||||
|
||||
Result is cached per plugin_id after the first lookup to avoid calling
|
||||
config_manager.get_config() — which returns a full dict copy — on every
|
||||
tick of the 30-fps display loop. The cache is invalidated when a plugin
|
||||
is loaded or unloaded.
|
||||
"""
|
||||
# Check manifest first
|
||||
if plugin_id in self._update_interval_cache:
|
||||
return self._update_interval_cache[plugin_id]
|
||||
|
||||
interval: Optional[float] = None
|
||||
|
||||
# 1. Manifest (immutable after load — preferred source)
|
||||
manifest = self.plugin_manifests.get(plugin_id, {})
|
||||
update_interval = manifest.get('update_interval')
|
||||
|
||||
if update_interval:
|
||||
raw = manifest.get('update_interval')
|
||||
if raw is not None:
|
||||
try:
|
||||
return float(update_interval)
|
||||
interval = float(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Check plugin config
|
||||
if self.config_manager:
|
||||
|
||||
# 2. Plugin config (mutable; only read once and then cached)
|
||||
if interval is None and self.config_manager:
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
plugin_config = config.get(plugin_id, {})
|
||||
update_interval = plugin_config.get('update_interval')
|
||||
if update_interval:
|
||||
raw = config.get(plugin_id, {}).get('update_interval')
|
||||
if raw is not None:
|
||||
try:
|
||||
return float(update_interval)
|
||||
interval = float(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception as e:
|
||||
except (ConfigError, OSError, ValueError, TypeError) as e:
|
||||
self.logger.debug("Could not get update interval from config: %s", e)
|
||||
|
||||
# Default: 60 seconds
|
||||
return 60.0
|
||||
|
||||
# 3. Default
|
||||
if interval is None:
|
||||
interval = 60.0
|
||||
|
||||
self._update_interval_cache[plugin_id] = interval
|
||||
return interval
|
||||
|
||||
def _record_update_failure(
|
||||
self,
|
||||
|
||||
@@ -185,13 +185,19 @@ class StateReconciliation:
|
||||
message=f"Reconciliation failed: {str(e)}"
|
||||
)
|
||||
|
||||
# Top-level config keys that are NOT plugins
|
||||
# Top-level config keys that are NOT plugins.
|
||||
# Includes both config.json structural keys and config_secrets.json top-level
|
||||
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
|
||||
_SYSTEM_CONFIG_KEYS = frozenset({
|
||||
'web_display_autostart', 'timezone', 'location', 'display',
|
||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
||||
'dim_schedule', 'network', 'system', 'schedule',
|
||||
# Multi-display sync config (config.json structural key)
|
||||
'sync',
|
||||
# Secrets file top-level keys (merged in by load_config)
|
||||
'github', 'youtube',
|
||||
})
|
||||
|
||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
@@ -334,15 +340,15 @@ class StateReconciliation:
|
||||
# Check: Enabled state mismatch
|
||||
config_enabled = config.get('enabled', False)
|
||||
state_mgr_enabled = state_mgr.get('enabled')
|
||||
|
||||
|
||||
if state_mgr_enabled is not None and config_enabled != state_mgr_enabled:
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
||||
fix_action=FixAction.AUTO_FIX,
|
||||
current_state={'enabled': config_enabled},
|
||||
expected_state={'enabled': state_mgr_enabled},
|
||||
current_state={'enabled': state_mgr_enabled},
|
||||
expected_state={'enabled': config_enabled},
|
||||
can_auto_fix=True
|
||||
))
|
||||
|
||||
@@ -365,15 +371,23 @@ class StateReconciliation:
|
||||
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||
# Sync enabled state from state manager to config
|
||||
expected_enabled = inconsistency.expected_state.get('enabled')
|
||||
config = self.config_manager.load_config()
|
||||
if inconsistency.plugin_id not in config:
|
||||
config[inconsistency.plugin_id] = {}
|
||||
config[inconsistency.plugin_id]['enabled'] = expected_enabled
|
||||
self.config_manager.save_config(config)
|
||||
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}")
|
||||
return True
|
||||
# config.json is the user-editable source of truth for enabled state.
|
||||
# Bring the state manager in sync with config rather than the reverse,
|
||||
# so that manual config edits (or the state left behind after an
|
||||
# uninstall+reinstall cycle) don't silently override the user's intent.
|
||||
config_enabled = inconsistency.expected_state.get('enabled')
|
||||
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
|
||||
if success:
|
||||
self.logger.info(
|
||||
f"Fixed: Synced state manager enabled={config_enabled} for "
|
||||
f"{inconsistency.plugin_id} to match config"
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Failed to sync state manager enabled={config_enabled} for "
|
||||
f"{inconsistency.plugin_id}"
|
||||
)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||
|
||||
@@ -5,6 +5,7 @@ Handles plugin discovery, installation, updates, and uninstallation
|
||||
from both the official registry and custom GitHub repositories.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import json
|
||||
import stat
|
||||
@@ -21,6 +22,8 @@ from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any, Tuple
|
||||
import logging
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from src.common.permission_utils import sudo_remove_directory
|
||||
|
||||
try:
|
||||
@@ -356,7 +359,8 @@ class PluginStoreManager:
|
||||
# Extract owner/repo from URL
|
||||
try:
|
||||
# Handle different URL formats
|
||||
if 'github.com' in repo_url:
|
||||
_parsed_url = urlparse(repo_url)
|
||||
if _parsed_url.hostname in ('github.com', 'www.github.com'):
|
||||
parts = repo_url.strip('/').split('/')
|
||||
if len(parts) >= 2:
|
||||
owner = parts[-2]
|
||||
@@ -518,9 +522,10 @@ class PluginStoreManager:
|
||||
# Try to find plugins.json in common locations
|
||||
# First try root directory
|
||||
registry_urls = []
|
||||
|
||||
|
||||
# Extract owner/repo from URL
|
||||
if 'github.com' in repo_url:
|
||||
_parsed_repo_url = urlparse(repo_url)
|
||||
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
|
||||
parts = repo_url.split('/')
|
||||
if len(parts) >= 2:
|
||||
owner = parts[-2]
|
||||
@@ -775,7 +780,8 @@ class PluginStoreManager:
|
||||
try:
|
||||
# Convert repo URL to raw content URL
|
||||
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
|
||||
if 'github.com' in repo_url:
|
||||
_parsed_manifest_url = urlparse(repo_url)
|
||||
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
|
||||
# Handle different URL formats
|
||||
repo_url = repo_url.rstrip('/')
|
||||
if repo_url.endswith('.git'):
|
||||
@@ -1750,6 +1756,12 @@ class PluginStoreManager:
|
||||
timeout=300
|
||||
)
|
||||
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
||||
# Write hash marker so plugin_loader skips redundant pip run on next startup
|
||||
try:
|
||||
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
|
||||
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
||||
@@ -7,13 +7,22 @@ Provides base classes and utilities for testing LEDMatrix plugins.
|
||||
from .plugin_test_base import PluginTestCase
|
||||
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
||||
from .visual_display_manager import VisualTestDisplayManager
|
||||
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||
from .sizes import (
|
||||
DEFAULT_TEST_SIZES, SUPPORTED_SIZES, resolve_test_sizes, size_label,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'PluginTestCase',
|
||||
'VisualTestDisplayManager',
|
||||
'BoundsCheckingDisplayManager',
|
||||
'MockDisplayManager',
|
||||
'MockCacheManager',
|
||||
'MockConfigManager',
|
||||
'MockPluginManager',
|
||||
'DEFAULT_TEST_SIZES',
|
||||
'SUPPORTED_SIZES',
|
||||
'resolve_test_sizes',
|
||||
'size_label',
|
||||
]
|
||||
|
||||
|
||||
129
src/plugin_system/testing/bounds_display_manager.py
Normal file
129
src/plugin_system/testing/bounds_display_manager.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Bounds-checking display manager.
|
||||
|
||||
A VisualTestDisplayManager that draws onto an oversized canvas (the declared
|
||||
panel size plus a right/bottom margin) while still reporting the declared size
|
||||
to the plugin. Content that a plugin draws past the right or bottom edge lands
|
||||
in the margin instead of being silently clipped by PIL, so the harness can
|
||||
detect overflow — the classic symptom of hardcoded coordinates or fonts/icons
|
||||
that don't scale down to a smaller panel.
|
||||
|
||||
Limitations (documented on purpose):
|
||||
- Overflow past the LEFT or TOP edge (negative coordinates) is still clipped by
|
||||
PIL and not detected here. The dominant real-world breakage is content that is
|
||||
too wide/tall for a smaller panel, which this catches.
|
||||
- BDF text is clipped to the declared bounds by the parent's bitmap drawer, so
|
||||
BDF overflow is not flagged. Golden-image regression covers those plugins.
|
||||
- If a plugin replaces the canvas with its own image (display_manager.image = ...),
|
||||
the margin can't be measured and overflow is reported as undetermined (None).
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .sizes import DEFAULT_TEST_SIZES
|
||||
from .visual_display_manager import VisualTestDisplayManager, _MatrixProxy
|
||||
|
||||
# Smallest extra band kept on the right/bottom so a few pixels of overflow are
|
||||
# still visible even on the largest panel in a run.
|
||||
_BASE_MARGIN = 16
|
||||
# Fallback overflow reference when a caller doesn't pass one: the largest shape
|
||||
# in the default sample. We extend every (smaller) canvas out to at least this
|
||||
# size so content drawn at a coordinate meant for a bigger build — e.g. x=200 on
|
||||
# a 64-wide panel — lands in the padded region and is flagged, instead of being
|
||||
# clipped off-canvas and read as a false pass.
|
||||
_DEFAULT_EXTENT_WIDTH = max(w for w, _ in DEFAULT_TEST_SIZES)
|
||||
_DEFAULT_EXTENT_HEIGHT = max(h for _, h in DEFAULT_TEST_SIZES)
|
||||
|
||||
|
||||
class BoundsCheckingDisplayManager(VisualTestDisplayManager):
|
||||
"""Detects drawing that overflows the declared panel size."""
|
||||
|
||||
# Kept for backwards compatibility; real padding is computed per-axis below.
|
||||
MARGIN = _BASE_MARGIN
|
||||
|
||||
def __init__(self, width: int = 128, height: int = 32,
|
||||
overflow_extent: Optional[Tuple[int, int]] = None):
|
||||
self._declared_width = int(width)
|
||||
self._declared_height = int(height)
|
||||
# Pad the canvas out to at least `overflow_extent` (the largest panel
|
||||
# this run cares about) plus a base margin, so coordinates meant for a
|
||||
# bigger build are caught — not clipped — when rendering a smaller panel.
|
||||
# Defaults to the largest shape in the sample when no run is known.
|
||||
ext_w, ext_h = overflow_extent or (_DEFAULT_EXTENT_WIDTH, _DEFAULT_EXTENT_HEIGHT)
|
||||
self._canvas_width = max(self._declared_width, int(ext_w)) + _BASE_MARGIN
|
||||
self._canvas_height = max(self._declared_height, int(ext_h)) + _BASE_MARGIN
|
||||
# Parent builds the (oversized) backing canvas + fonts.
|
||||
super().__init__(self._canvas_width, self._canvas_height)
|
||||
# Plugins must see the DECLARED size, not the padded canvas size.
|
||||
self.matrix = _MatrixProxy(self._declared_width, self._declared_height)
|
||||
|
||||
# -- declared dimensions (override parent's image-derived properties) --
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self._declared_width
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return self._declared_height
|
||||
|
||||
@property
|
||||
def display_width(self) -> int:
|
||||
return self._declared_width
|
||||
|
||||
@property
|
||||
def display_height(self) -> int:
|
||||
return self._declared_height
|
||||
|
||||
# -- overflow detection --
|
||||
|
||||
def _canvas_is_padded(self) -> bool:
|
||||
return self.image.size == (self._canvas_width, self._canvas_height)
|
||||
|
||||
def check_overflow(self) -> Optional[Tuple[int, int, int, int]]:
|
||||
"""Bounding box (in full-canvas coords) of any drawing beyond the
|
||||
declared panel, or None if nothing overflowed / undetermined."""
|
||||
if not self._canvas_is_padded():
|
||||
return None
|
||||
|
||||
exp_w = self._canvas_width
|
||||
exp_h = self._canvas_height
|
||||
boxes = []
|
||||
|
||||
right = self.image.crop((self._declared_width, 0, exp_w, exp_h)).getbbox()
|
||||
if right:
|
||||
boxes.append((right[0] + self._declared_width, right[1],
|
||||
right[2] + self._declared_width, right[3]))
|
||||
|
||||
bottom = self.image.crop((0, self._declared_height, exp_w, exp_h)).getbbox()
|
||||
if bottom:
|
||||
boxes.append((bottom[0], bottom[1] + self._declared_height,
|
||||
bottom[2], bottom[3] + self._declared_height))
|
||||
|
||||
if not boxes:
|
||||
return None
|
||||
return (
|
||||
min(b[0] for b in boxes), min(b[1] for b in boxes),
|
||||
max(b[2] for b in boxes), max(b[3] for b in boxes),
|
||||
)
|
||||
|
||||
# -- snapshot/image accessors return the cropped, true-panel image --
|
||||
|
||||
def declared_image(self):
|
||||
"""The visible panel: the canvas cropped to the declared size."""
|
||||
if self._canvas_is_padded():
|
||||
return self.image.crop((0, 0, self._declared_width, self._declared_height))
|
||||
return self.image
|
||||
|
||||
def save_snapshot(self, path: str) -> None:
|
||||
self.declared_image().save(path, format='PNG')
|
||||
|
||||
def get_image(self):
|
||||
return self.declared_image()
|
||||
|
||||
def get_image_base64(self) -> str:
|
||||
import base64
|
||||
import io
|
||||
buffer = io.BytesIO()
|
||||
self.declared_image().save(buffer, format='PNG')
|
||||
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
314
src/plugin_system/testing/harness.py
Normal file
314
src/plugin_system/testing/harness.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Plugin safety harness.
|
||||
|
||||
Renders a plugin across every declared screen (mode) and every supported matrix
|
||||
size, capturing crashes and overflow. Used by scripts/check_plugin.py and the
|
||||
pytest matrix test to guarantee a plugin change doesn't break a screen at a size
|
||||
the author didn't try.
|
||||
|
||||
The render flow mirrors scripts/render_plugin.py (same PluginLoader call), but
|
||||
this module adds: multi-size iteration, per-mode rendering, overflow detection
|
||||
via BoundsCheckingDisplayManager, and golden-image comparison.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import http.client
|
||||
import inspect
|
||||
import socket
|
||||
import ssl
|
||||
import urllib.error
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||
from .loading import load_config_defaults, load_manifest
|
||||
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
||||
|
||||
logger = get_logger("[Plugin Harness]")
|
||||
|
||||
|
||||
def _tolerated_update_errors() -> Tuple[type, ...]:
|
||||
"""Exception types from update() we treat as a tolerated no-connectivity
|
||||
failure (expected in CI / headless dev) rather than a real plugin bug.
|
||||
|
||||
Anything NOT in this set is a genuine regression — a plugin that lets a
|
||||
non-network exception escape update() should fail the harness, not pass
|
||||
green because display() happened to survive.
|
||||
"""
|
||||
types: List[type] = [
|
||||
ConnectionError, TimeoutError, # builtins
|
||||
socket.gaierror, socket.timeout, # DNS / socket timeouts
|
||||
ssl.SSLError,
|
||||
urllib.error.URLError,
|
||||
http.client.HTTPException,
|
||||
]
|
||||
try: # requests is optional; cover its whole error tree when present
|
||||
import requests
|
||||
types.append(requests.exceptions.RequestException)
|
||||
except ImportError: # pragma: no cover - requests not installed
|
||||
logger.debug("requests not installed; its connectivity errors won't be specifically tolerated")
|
||||
return tuple(types)
|
||||
|
||||
|
||||
_TOLERATED_UPDATE_ERRORS = _tolerated_update_errors()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderResult:
|
||||
"""Outcome of rendering one (size, mode) of a plugin."""
|
||||
plugin_id: str
|
||||
width: int
|
||||
height: int
|
||||
mode: str
|
||||
image: Optional[Image.Image] = None
|
||||
error: Optional[str] = None # fatal: load/display crash, or a non-network update() error
|
||||
update_error: Optional[str] = None # tolerated: connectivity error from update() (no network in CI)
|
||||
overflow: Optional[Tuple[int, int, int, int]] = None # bbox past the panel
|
||||
# golden comparison (populated only when a golden was provided)
|
||||
golden_checked: bool = False
|
||||
golden_ok: Optional[bool] = None
|
||||
golden_diff_pixels: int = 0
|
||||
golden_max_delta: int = 0
|
||||
|
||||
@property
|
||||
def size_label(self) -> str:
|
||||
return size_label(self.width, self.height)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Phase-1 pass: rendered without crashing and without overflow, and if a
|
||||
golden was checked it matched."""
|
||||
if self.error is not None or self.overflow is not None:
|
||||
return False
|
||||
if self.golden_checked and self.golden_ok is False:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
|
||||
"""Enumerate a plugin's screens: instance.modes wins, then manifest
|
||||
display_modes, then the plugin id as a single mode."""
|
||||
modes = getattr(plugin_instance, "modes", None)
|
||||
if modes:
|
||||
return [str(m) for m in modes]
|
||||
declared = manifest.get("display_modes")
|
||||
if declared:
|
||||
return [str(m) for m in declared]
|
||||
return [plugin_id]
|
||||
|
||||
|
||||
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||
config: Dict[str, Any], mock_data: Dict[str, Any],
|
||||
display_manager: Any) -> Any:
|
||||
"""Load and construct a plugin instance with mocked managers."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.testing import MockCacheManager, MockPluginManager
|
||||
|
||||
cache_manager = MockCacheManager()
|
||||
for key, value in (mock_data or {}).items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
loader = PluginLoader()
|
||||
plugin_instance, _module = loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=MockPluginManager(),
|
||||
install_deps=False,
|
||||
)
|
||||
return plugin_instance
|
||||
|
||||
|
||||
def _render_mode(plugin_instance: Any, mode: str) -> None:
|
||||
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||
drive the plugin's internal mode state machine (first display() call renders
|
||||
modes[current_mode_index] when current_display_mode is None)."""
|
||||
sig = inspect.signature(plugin_instance.display)
|
||||
if "display_mode" in sig.parameters:
|
||||
plugin_instance.display(force_clear=True, display_mode=mode)
|
||||
return
|
||||
|
||||
modes = getattr(plugin_instance, "modes", None)
|
||||
if modes and mode in modes:
|
||||
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||
if hasattr(plugin_instance, "current_display_mode"):
|
||||
plugin_instance.current_display_mode = None
|
||||
plugin_instance.display(force_clear=False)
|
||||
|
||||
|
||||
def _freeze(freeze_time: Optional[str]):
|
||||
"""Context manager that freezes wall-clock time when freeze_time is given,
|
||||
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
|
||||
if not freeze_time:
|
||||
return contextlib.nullcontext()
|
||||
try:
|
||||
from freezegun import freeze_time as _ft
|
||||
except ImportError as e: # pragma: no cover - only hit without the dep
|
||||
raise RuntimeError(
|
||||
"freeze_time requires the 'freezegun' package (pip install freezegun)"
|
||||
) from e
|
||||
return _ft(freeze_time)
|
||||
|
||||
|
||||
def render_plugin_matrix(
|
||||
plugin_id: str,
|
||||
plugin_dir: Path,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
mock_data: Optional[Dict[str, Any]] = None,
|
||||
sizes: Optional[List[Tuple[int, int]]] = None,
|
||||
run_update: bool = True,
|
||||
freeze_time: Optional[str] = None,
|
||||
) -> List[RenderResult]:
|
||||
"""Render every (size, mode) combination for a plugin.
|
||||
|
||||
Returns a flat list of RenderResult. A fresh plugin instance is built per
|
||||
(size, mode) so state never leaks between screens. Pass freeze_time (e.g.
|
||||
"2025-08-01 15:25:00") to make time-dependent plugins reproducible.
|
||||
"""
|
||||
plugin_dir = Path(plugin_dir)
|
||||
manifest = load_manifest(plugin_dir)
|
||||
# Start from config_schema.json defaults so the plugin behaves like a real
|
||||
# install; explicit caller config still wins over a schema default.
|
||||
config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
|
||||
sizes = sizes or DEFAULT_TEST_SIZES
|
||||
results: List[RenderResult] = []
|
||||
|
||||
# The largest panel in this run. Every (smaller) canvas is padded out to it
|
||||
# so a coordinate meant for the biggest configuration is still caught when
|
||||
# rendering a smaller one, instead of being clipped into a false pass.
|
||||
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
|
||||
|
||||
with _freeze(freeze_time):
|
||||
for width, height in sizes:
|
||||
results.extend(_render_size(
|
||||
plugin_id, manifest, plugin_dir, config, mock_data or {},
|
||||
width, height, run_update, extent,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||
width, height, run_update, extent) -> List[RenderResult]:
|
||||
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
|
||||
results: List[RenderResult] = []
|
||||
|
||||
# Discover modes once per size (instance build can depend on config).
|
||||
try:
|
||||
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
|
||||
modes = list_modes(probe, manifest, plugin_id)
|
||||
except Exception as e: # noqa: BLE001 — surface any load failure as a result
|
||||
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
|
||||
|
||||
for mode in modes:
|
||||
result = RenderResult(plugin_id, width, height, mode)
|
||||
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||
try:
|
||||
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
|
||||
if run_update:
|
||||
try:
|
||||
inst.update()
|
||||
except _TOLERATED_UPDATE_ERRORS as e:
|
||||
# Expected when CI / headless dev has no network: record it
|
||||
# (surfaced in the report) but don't fail the run.
|
||||
result.update_error = repr(e)
|
||||
logger.debug("update() connectivity error for %s [%s]: %s", plugin_id, mode, e)
|
||||
except Exception as e: # noqa: BLE001 — a non-network update() failure is a real bug
|
||||
# A regression in update() must not pass green just because
|
||||
# display() survives, so treat it as a failure of this render.
|
||||
result.error = repr(e)
|
||||
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||
plugin_id, mode, e)
|
||||
if result.error is None:
|
||||
_render_mode(inst, mode)
|
||||
result.image = dm.get_image()
|
||||
result.overflow = dm.check_overflow()
|
||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||
result.error = repr(e)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Golden-image comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compare_images(rendered: Image.Image, golden: Image.Image,
|
||||
max_delta: int = 0, max_diff_pixels: int = 0) -> Tuple[bool, int, int]:
|
||||
"""Compare two images. Returns (ok, diff_pixel_count, max_per_channel_delta).
|
||||
|
||||
Tolerances default to exact match; bump them only to absorb known platform
|
||||
anti-aliasing noise (requires a pinned Pillow + bundled fonts for stability).
|
||||
"""
|
||||
if rendered.size != golden.size:
|
||||
return False, rendered.size[0] * rendered.size[1], 255
|
||||
a = rendered.convert("RGB")
|
||||
b = golden.convert("RGB")
|
||||
diff = ImageChops.difference(a, b)
|
||||
bbox = diff.getbbox()
|
||||
if bbox is None:
|
||||
return True, 0, 0
|
||||
# Count pixels whose largest per-channel delta exceeds the allowed tolerance,
|
||||
# and track the worst delta seen (for reporting).
|
||||
diff_pixels = 0
|
||||
observed_max = 0
|
||||
for px in diff.crop(bbox).getdata():
|
||||
m = max(px) if isinstance(px, tuple) else px
|
||||
if m > observed_max:
|
||||
observed_max = m
|
||||
if m > max_delta:
|
||||
diff_pixels += 1
|
||||
# Pass when the number of out-of-tolerance pixels is within budget.
|
||||
ok = diff_pixels <= max_diff_pixels
|
||||
return ok, diff_pixels, observed_max
|
||||
|
||||
|
||||
def golden_path(golden_dir: Path, width: int, height: int, mode: str) -> Path:
|
||||
"""Location of a golden image: <golden_dir>/<WxH>/<mode>.png.
|
||||
|
||||
The mode is sanitized to a safe basename so a mode name with '/' or '..'
|
||||
can't read or write outside the golden directory.
|
||||
"""
|
||||
return Path(golden_dir) / size_label(width, height) / f"{safe_mode_filename(mode)}.png"
|
||||
|
||||
|
||||
def compare_to_goldens(results: List[RenderResult], golden_dir: Path,
|
||||
max_delta: int = 0, max_diff_pixels: int = 0) -> List[RenderResult]:
|
||||
"""Compare rendered results against committed goldens, mutating each result's
|
||||
golden_* fields. Results with no golden file on disk are left unchecked."""
|
||||
for r in results:
|
||||
if r.image is None:
|
||||
continue
|
||||
gp = golden_path(golden_dir, r.width, r.height, r.mode)
|
||||
if not gp.exists():
|
||||
continue
|
||||
r.golden_checked = True
|
||||
with Image.open(gp) as g:
|
||||
ok, diff_pixels, observed_max = compare_images(
|
||||
r.image, g, max_delta=max_delta, max_diff_pixels=max_diff_pixels)
|
||||
r.golden_ok = ok
|
||||
r.golden_diff_pixels = diff_pixels
|
||||
r.golden_max_delta = observed_max
|
||||
return results
|
||||
|
||||
|
||||
def write_goldens(results: List[RenderResult], golden_dir: Path) -> int:
|
||||
"""Write each successfully-rendered result to its golden path. Returns count."""
|
||||
written = 0
|
||||
for r in results:
|
||||
if r.image is None or r.error is not None:
|
||||
continue
|
||||
gp = golden_path(golden_dir, r.width, r.height, r.mode)
|
||||
gp.parent.mkdir(parents=True, exist_ok=True)
|
||||
r.image.save(gp, format="PNG")
|
||||
written += 1
|
||||
return written
|
||||
82
src/plugin_system/testing/loading.py
Normal file
82
src/plugin_system/testing/loading.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Shared helpers for loading a plugin headlessly.
|
||||
|
||||
Used by scripts/render_plugin.py, scripts/check_plugin.py, and the harness so
|
||||
plugin discovery / manifest / config-default logic lives in exactly one place.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence, Union
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
||||
"""Find a plugin directory by searching multiple paths."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in search_dirs:
|
||||
search_path = Path(search_dir)
|
||||
if not search_path.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_path)
|
||||
if result:
|
||||
return Path(result)
|
||||
return None
|
||||
|
||||
|
||||
def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""Load and return manifest.json from a plugin directory."""
|
||||
manifest_path = Path(plugin_dir) / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in schema.get('properties', {}).items():
|
||||
if isinstance(prop, dict) and 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""Optional per-plugin harness settings from <plugin>/test/harness.json.
|
||||
|
||||
Lets a plugin opt into golden-image testing by declaring how to render it
|
||||
deterministically. All keys optional:
|
||||
{
|
||||
"config": {...}, # config overrides
|
||||
"mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures
|
||||
"freeze_time": "2025-08-01 15:25:00",
|
||||
"skip_update": false
|
||||
}
|
||||
Returns {} when no harness.json exists.
|
||||
"""
|
||||
spec_path = Path(plugin_dir) / 'test' / 'harness.json'
|
||||
if not spec_path.exists():
|
||||
return {}
|
||||
with open(spec_path, 'r') as f:
|
||||
spec = json.load(f)
|
||||
|
||||
# Resolve mock_data path and inline its contents for convenience.
|
||||
mock_rel = spec.get('mock_data')
|
||||
if mock_rel:
|
||||
mock_path = Path(plugin_dir) / mock_rel
|
||||
if not mock_path.exists():
|
||||
# A declared-but-missing fixture is a harness config error: failing
|
||||
# loudly beats silently rendering the plugin with no mock data.
|
||||
raise FileNotFoundError(
|
||||
f"harness.json references mock_data '{mock_rel}' but "
|
||||
f"{mock_path} does not exist"
|
||||
)
|
||||
with open(mock_path, 'r') as mf:
|
||||
spec['mock_data_contents'] = json.load(mf)
|
||||
return spec
|
||||
@@ -63,11 +63,23 @@ class MockCacheManager:
|
||||
"""Mock cache manager for testing."""
|
||||
|
||||
def __init__(self):
|
||||
import shutil
|
||||
import tempfile
|
||||
import weakref
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._cache_timestamps: Dict[str, float] = {}
|
||||
self.get_calls = []
|
||||
self.set_calls = []
|
||||
self.delete_calls = []
|
||||
# Real temp dir for plugins that write/read files under cache_dir.
|
||||
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
|
||||
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
|
||||
self._finalizer = weakref.finalize(
|
||||
self, shutil.rmtree, self.cache_dir, ignore_errors=True)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Remove the temp cache directory created for this instance."""
|
||||
self._finalizer()
|
||||
|
||||
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
|
||||
"""Get a value from cache."""
|
||||
|
||||
120
src/plugin_system/testing/sizes.py
Normal file
120
src/plugin_system/testing/sizes.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
LED matrix sizes the plugin safety harness renders against.
|
||||
|
||||
There is no fixed set of "supported" panel sizes — an RGB matrix build can be
|
||||
any width/height and configuration (square, rectangle, 2x2, 4x4, 8x2, long
|
||||
strips, tall stacks, ...). Plugins are expected to read width/height
|
||||
dynamically and lay themselves out accordingly, so the harness's job is to
|
||||
prove a plugin survives a *spread* of shapes, not a canonical list.
|
||||
|
||||
`DEFAULT_TEST_SIZES` is therefore a representative SAMPLE chosen to span the
|
||||
axes of variation (narrow, wide, square, tall, small, long), not an
|
||||
exhaustive or authoritative list. Callers can override it entirely:
|
||||
|
||||
- CLI: scripts/check_plugin.py --sizes 8x16,64x64,256x32
|
||||
- pytest: LEDMATRIX_TEST_SIZES="8x16,64x64" env var (all plugins), or
|
||||
per-plugin test/harness.json {"sizes": [[8, 16], [64, 64]]}
|
||||
|
||||
so anyone can point the harness at the exact panel(s) their build uses.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
# A spread of real panel-grid arrangements (each module is 64x32), not a list of
|
||||
# "blessed" sizes. Each entry exercises a different layout assumption a plugin
|
||||
# might accidentally bake in. Annotations are the panel grid (cols x rows).
|
||||
DEFAULT_TEST_SIZES: List[Tuple[int, int]] = [
|
||||
(64, 32), # 1x1 — single panel, the tightest common rectangle
|
||||
(128, 32), # 2x1 — the baseline most plugins are tuned for
|
||||
(64, 64), # 1x2 — stacked, exercises tall-narrow centering
|
||||
(128, 64), # 2x2 — block, icon scaling / vertical centering
|
||||
(256, 32), # 4x1 — long strip, wide horizontal layout
|
||||
(128, 96), # 2x3 — tall, exercises vertical overflow
|
||||
(256, 128), # 4x4 — large block, both dimensions big at once
|
||||
]
|
||||
|
||||
# Backwards-compatible alias. Prefer DEFAULT_TEST_SIZES in new code — the old
|
||||
# name implied these were the only valid panel sizes, which they are not.
|
||||
SUPPORTED_SIZES = DEFAULT_TEST_SIZES
|
||||
|
||||
|
||||
def size_label(width: int, height: int) -> str:
|
||||
"""Human/path-friendly label for a size, e.g. '128x32'."""
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
def parse_size_token(token: str) -> Tuple[int, int]:
|
||||
"""Parse a single 'WxH' token into an (int, int) pair.
|
||||
|
||||
Raises ValueError (with a user-friendly message) on malformed input so
|
||||
callers can surface it however they like.
|
||||
"""
|
||||
cleaned = token.strip().lower()
|
||||
if "x" not in cleaned:
|
||||
raise ValueError(f"Invalid size '{token}' (expected WxH, e.g. 128x32)")
|
||||
w, h = cleaned.split("x", 1)
|
||||
try:
|
||||
width, height = int(w), int(h)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Invalid size '{token}' (expected numeric WxH, e.g. 128x32)"
|
||||
) from exc
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid size '{token}' (width and height must be positive, e.g. 128x32)"
|
||||
)
|
||||
return (width, height)
|
||||
|
||||
|
||||
def coerce_sizes(
|
||||
value: Union[str, Iterable[Sequence[int]], None]
|
||||
) -> Optional[List[Tuple[int, int]]]:
|
||||
"""Normalize a size spec into a list of (w, h) tuples, or None if empty.
|
||||
|
||||
Accepts a comma-separated 'WxH,WxH' string (CLI / env var) or an iterable
|
||||
of [w, h] / (w, h) pairs (harness.json). Returns None when value is falsy
|
||||
so callers can fall back to the default sample.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [parse_size_token(tok) for tok in value.split(",") if tok.strip()]
|
||||
sizes: List[Tuple[int, int]] = []
|
||||
for pair in value:
|
||||
w, h = pair # raises if not a 2-element sequence
|
||||
width, height = int(w), int(h)
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(f"Invalid size pair {pair!r} (width and height must be positive)")
|
||||
sizes.append((width, height))
|
||||
return sizes or None
|
||||
|
||||
|
||||
def resolve_test_sizes(
|
||||
spec_sizes: Union[str, Iterable[Sequence[int]], None] = None,
|
||||
) -> List[Tuple[int, int]]:
|
||||
"""Decide which sizes to render, by precedence:
|
||||
|
||||
1. LEDMATRIX_TEST_SIZES env var — a global "test on my hardware" override
|
||||
that wins for every plugin.
|
||||
2. spec_sizes — e.g. a per-plugin harness.json "sizes" list.
|
||||
3. DEFAULT_TEST_SIZES — the representative sample.
|
||||
"""
|
||||
env = coerce_sizes(os.environ.get("LEDMATRIX_TEST_SIZES"))
|
||||
if env:
|
||||
return env
|
||||
spec = coerce_sizes(spec_sizes)
|
||||
if spec:
|
||||
return spec
|
||||
return list(DEFAULT_TEST_SIZES)
|
||||
|
||||
|
||||
def safe_mode_filename(mode: str) -> str:
|
||||
"""A filesystem-safe basename for a plugin mode.
|
||||
|
||||
Mode names come from plugin metadata/render state, so a value containing
|
||||
'/' or '..' could otherwise escape the intended output directory. Collapse
|
||||
anything that isn't alphanumeric / dash / underscore to '_'.
|
||||
"""
|
||||
cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in mode)
|
||||
return cleaned or "mode"
|
||||
@@ -150,6 +150,18 @@ class WiFiManager:
|
||||
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
|
||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
||||
|
||||
# Once per process: remove a stale force-AP flag left by a prior crash.
|
||||
# Guard with a class-level flag so the nmcli AP-state check only runs
|
||||
# once even though WiFiManager is instantiated per-request.
|
||||
if not WiFiManager._startup_cleanup_done:
|
||||
WiFiManager._startup_cleanup_done = True
|
||||
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
||||
logger.debug("Removed stale force-AP flag on startup (AP not active)")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Could not remove stale force-AP flag: {exc}")
|
||||
|
||||
def _show_led_message(self, message: str, duration: int = 5):
|
||||
"""
|
||||
@@ -474,7 +486,10 @@ class WiFiManager:
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if '/' in line:
|
||||
ip_address = line.split('/')[0].strip()
|
||||
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
|
||||
# bare "x.x.x.x/prefix" is also accepted defensively.
|
||||
_, sep, rest = line.partition(':')
|
||||
ip_address = (rest if sep else line).split('/')[0].strip()
|
||||
break
|
||||
|
||||
# Final fallback: Get signal strength by matching SSID in WiFi list
|
||||
@@ -500,6 +515,13 @@ class WiFiManager:
|
||||
|
||||
# Check if AP mode is active
|
||||
ap_active = self._is_ap_mode_active()
|
||||
# wlan0 shows as "connected" in AP mode; clear client-station fields so
|
||||
# callers don't mistake the AP for an outbound WiFi connection.
|
||||
if ap_active and wifi_connected:
|
||||
wifi_connected = False
|
||||
ssid = None
|
||||
ip_address = None
|
||||
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
|
||||
|
||||
return WiFiStatus(
|
||||
connected=wifi_connected,
|
||||
@@ -690,6 +712,10 @@ class WiFiManager:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
|
||||
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
|
||||
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
|
||||
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
|
||||
_startup_cleanup_done: bool = False
|
||||
|
||||
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||
@@ -1367,7 +1393,7 @@ class WiFiManager:
|
||||
logger.error(f"Failed to restore original connection: {original_ssid}")
|
||||
# Trigger AP mode as last resort
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode()
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, "Connection failed and restoration failed. AP mode enabled."
|
||||
@@ -1379,7 +1405,7 @@ class WiFiManager:
|
||||
elif not success:
|
||||
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode()
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, "Connection failed. AP mode enabled."
|
||||
@@ -1400,7 +1426,7 @@ class WiFiManager:
|
||||
logger.error(f"Failed to restore after exception: {restore_error}")
|
||||
# Last resort: enable AP mode
|
||||
try:
|
||||
self.enable_ap_mode()
|
||||
self.enable_ap_mode(force=True)
|
||||
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
|
||||
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
||||
return False, str(e)
|
||||
@@ -1464,26 +1490,29 @@ class WiFiManager:
|
||||
# Show LED message
|
||||
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
||||
|
||||
# First, check if connection already exists and try to activate it
|
||||
# NetworkManager connection names might not match SSID exactly, so search by SSID
|
||||
check_result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
# Find existing NM connection for this SSID.
|
||||
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
|
||||
# so list all wifi connections then query each one's SSID individually.
|
||||
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
||||
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
existing_conn_name = None
|
||||
if check_result.returncode == 0:
|
||||
for line in check_result.stdout.strip().split('\n'):
|
||||
if ':' in line:
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2:
|
||||
conn_name = parts[0].strip()
|
||||
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
|
||||
if conn_ssid == ssid:
|
||||
existing_conn_name = conn_name
|
||||
break
|
||||
if list_result.returncode == 0:
|
||||
for line in list_result.stdout.strip().split('\n'):
|
||||
if ':' not in line:
|
||||
continue
|
||||
parts = line.split(':')
|
||||
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
|
||||
continue
|
||||
conn_name = parts[0].strip()
|
||||
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
||||
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
|
||||
existing_conn_name = conn_name
|
||||
break
|
||||
|
||||
# Also try direct lookup by SSID (in case connection name matches SSID)
|
||||
if not existing_conn_name:
|
||||
@@ -1855,7 +1884,7 @@ class WiFiManager:
|
||||
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
||||
return False
|
||||
|
||||
def enable_ap_mode(self) -> Tuple[bool, str]:
|
||||
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Enable access point mode
|
||||
|
||||
@@ -1877,20 +1906,29 @@ class WiFiManager:
|
||||
if not self._ensure_wifi_radio_enabled():
|
||||
return False, "WiFi radio is disabled and could not be enabled"
|
||||
|
||||
# Check if WiFi is connected
|
||||
# Check if WiFi is connected (skip when force=True)
|
||||
status = self.get_wifi_status()
|
||||
if status.connected:
|
||||
if not force and status.connected:
|
||||
return False, "Cannot enable AP mode while WiFi is connected"
|
||||
|
||||
# Check if Ethernet is connected
|
||||
if self._is_ethernet_connected():
|
||||
# Check if Ethernet is connected (skip when force=True)
|
||||
if not force and self._is_ethernet_connected():
|
||||
return False, "Cannot enable AP mode while Ethernet is connected"
|
||||
|
||||
if force:
|
||||
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
|
||||
|
||||
# Try hostapd/dnsmasq first (captive portal mode)
|
||||
if self.has_hostapd and self.has_dnsmasq:
|
||||
result = self._enable_ap_mode_hostapd()
|
||||
if result[0]:
|
||||
self._ap_enabled_at = time.time()
|
||||
if force:
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.touch()
|
||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
||||
return result
|
||||
|
||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||
@@ -1900,6 +1938,12 @@ class WiFiManager:
|
||||
result = self._enable_ap_mode_nmcli_hotspot()
|
||||
if result[0]:
|
||||
self._ap_enabled_at = time.time()
|
||||
if force:
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.touch()
|
||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
||||
return result
|
||||
|
||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||
@@ -2091,8 +2135,14 @@ class WiFiManager:
|
||||
self._clear_led_message()
|
||||
return False, "AP started but captive-portal redirect setup failed"
|
||||
|
||||
# Verify the AP is actually running
|
||||
status = self._get_ap_status_nmcli()
|
||||
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
|
||||
status = {}
|
||||
for _attempt in range(5):
|
||||
status = self._get_ap_status_nmcli()
|
||||
if status.get('active'):
|
||||
break
|
||||
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
|
||||
time.sleep(2)
|
||||
if status.get('active'):
|
||||
ip = status.get('ip', '192.168.4.1')
|
||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||
@@ -2290,6 +2340,7 @@ class WiFiManager:
|
||||
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
||||
|
||||
self._ap_enabled_at = None
|
||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
||||
logger.info("AP mode disabled successfully")
|
||||
return True, "AP mode disabled"
|
||||
except Exception as e:
|
||||
@@ -2478,22 +2529,29 @@ address=/detectportal.firefox.com/192.168.4.1
|
||||
else:
|
||||
logger.warning(f"Failed to enable AP mode: {message}")
|
||||
elif not should_have_ap and ap_active:
|
||||
# Should not have AP but do - disable AP mode
|
||||
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
|
||||
if status.connected or ethernet_connected:
|
||||
# Should not have AP but do - check if it was manually force-enabled
|
||||
force_active = self._FORCE_AP_FLAG_PATH.exists()
|
||||
if status.connected:
|
||||
# WiFi connected: always disable AP (user successfully configured WiFi)
|
||||
success, message = self.disable_ap_mode()
|
||||
if success:
|
||||
if status.connected:
|
||||
logger.info("Auto-disabled AP mode (WiFi connected)")
|
||||
elif ethernet_connected:
|
||||
logger.info("Auto-disabled AP mode (Ethernet connected)")
|
||||
self._disconnected_checks = 0 # Reset counter
|
||||
logger.info("Auto-disabled AP mode (WiFi connected)")
|
||||
self._disconnected_checks = 0
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
||||
elif ethernet_connected and not force_active:
|
||||
# Ethernet connected, AP not manually forced: auto-disable
|
||||
success, message = self.disable_ap_mode()
|
||||
if success:
|
||||
logger.info("Auto-disabled AP mode (Ethernet connected)")
|
||||
self._disconnected_checks = 0
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
||||
elif ethernet_connected and force_active:
|
||||
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
|
||||
elif not auto_enable:
|
||||
# AP is active but auto_enable is disabled - this means it was manually enabled
|
||||
# Don't disable it automatically, let it stay active
|
||||
logger.debug("AP mode is active (manually enabled), keeping active")
|
||||
|
||||
# Idle-timeout check: disable AP if no client has connected within the window.
|
||||
|
||||
@@ -49,9 +49,10 @@ class TestBasketballScoreboardPlugin(PluginTestBase):
|
||||
"""Test that plugin has display modes."""
|
||||
manifest = self.load_plugin_manifest(plugin_id)
|
||||
assert 'display_modes' in manifest
|
||||
assert 'basketball_live' in manifest['display_modes']
|
||||
assert 'basketball_recent' in manifest['display_modes']
|
||||
assert 'basketball_upcoming' in manifest['display_modes']
|
||||
# Manifest uses league-prefixed modes (nba_, wnba_, ncaam_, ncaaw_)
|
||||
assert 'nba_live' in manifest['display_modes']
|
||||
assert 'nba_recent' in manifest['display_modes']
|
||||
assert 'nba_upcoming' in manifest['display_modes']
|
||||
|
||||
def test_plugin_has_get_display_modes(self, plugin_id):
|
||||
"""Test that plugin can return display modes."""
|
||||
|
||||
182
test/plugins/test_harness.py
Normal file
182
test/plugins/test_harness.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Unit tests for the plugin safety harness primitives:
|
||||
bounds detection, image comparison, and mode enumeration.
|
||||
|
||||
These don't load real plugins, so they run anywhere (including core CI where
|
||||
plugin-repos is empty).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.testing.bounds_display_manager import BoundsCheckingDisplayManager
|
||||
from src.plugin_system.testing.harness import (
|
||||
_TOLERATED_UPDATE_ERRORS, compare_images, list_modes,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import (
|
||||
DEFAULT_TEST_SIZES, coerce_sizes, parse_size_token, resolve_test_sizes,
|
||||
)
|
||||
|
||||
|
||||
class TestBoundsDetection:
|
||||
def test_reports_declared_size_not_canvas_size(self):
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
assert dm.width == 64 and dm.height == 32
|
||||
assert dm.matrix.width == 64 and dm.matrix.height == 32
|
||||
# Backing canvas is padded out past the declared panel so far-overshoot
|
||||
# coordinates land on-canvas and get flagged instead of clipped.
|
||||
canvas_w, canvas_h = dm.image.size
|
||||
assert canvas_w > 64 and canvas_h > 32
|
||||
|
||||
def test_far_overshoot_on_small_panel_is_detected(self):
|
||||
# A coordinate meant for a wide build (x past 64) must still be caught
|
||||
# when the declared panel is only 64 wide.
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
dm.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||
bbox = dm.check_overflow()
|
||||
assert bbox is not None
|
||||
assert bbox[0] >= 64
|
||||
|
||||
def test_in_bounds_drawing_has_no_overflow(self):
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
dm.draw.rectangle([0, 0, 63, 31], fill=(255, 255, 255))
|
||||
assert dm.check_overflow() is None
|
||||
|
||||
def test_right_overflow_is_detected(self):
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
# Draw a few pixels past the right edge.
|
||||
dm.draw.rectangle([60, 5, 70, 10], fill=(255, 0, 0))
|
||||
bbox = dm.check_overflow()
|
||||
assert bbox is not None
|
||||
assert bbox[0] >= 64 # overflow starts at or past the declared width
|
||||
|
||||
def test_bottom_overflow_is_detected(self):
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
dm.draw.rectangle([5, 30, 10, 40], fill=(0, 255, 0))
|
||||
bbox = dm.check_overflow()
|
||||
assert bbox is not None
|
||||
assert bbox[3] > 32 # overflow extends past the declared height
|
||||
|
||||
def test_declared_image_is_cropped_to_panel(self):
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
assert dm.get_image().size == (64, 32)
|
||||
|
||||
def test_snapshot_saves_cropped_panel(self, tmp_path):
|
||||
dm = BoundsCheckingDisplayManager(width=128, height=32)
|
||||
out = tmp_path / "snap.png"
|
||||
dm.save_snapshot(str(out))
|
||||
with Image.open(out) as img:
|
||||
assert img.size == (128, 32)
|
||||
|
||||
|
||||
class TestArbitraryPanelSizes:
|
||||
"""The harness must handle any panel shape, not a fixed supported list."""
|
||||
|
||||
def test_overflow_extent_pads_to_largest_in_run(self):
|
||||
# A wide run (extent 256) means content at x=200 on a 64-wide panel is
|
||||
# caught; the same draw with a small extent would be clipped (false pass).
|
||||
wide = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(256, 32))
|
||||
wide.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||
assert wide.check_overflow() is not None
|
||||
|
||||
tight = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(64, 32))
|
||||
tight.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||
assert tight.check_overflow() is None # clipped beyond the small canvas
|
||||
|
||||
def test_unusual_shapes_report_their_declared_size(self):
|
||||
for w, h in [(8, 2), (6, 6), (200, 8), (64, 96)]:
|
||||
dm = BoundsCheckingDisplayManager(width=w, height=h)
|
||||
assert dm.width == w and dm.height == h
|
||||
assert dm.matrix.width == w and dm.matrix.height == h
|
||||
|
||||
|
||||
class TestUpdateErrorClassification:
|
||||
"""update() may fail for lack of network (tolerated) but a logic bug must
|
||||
not pass green just because display() survives."""
|
||||
|
||||
def test_connectivity_errors_are_tolerated(self):
|
||||
import socket
|
||||
import urllib.error
|
||||
for exc in (ConnectionError("x"), TimeoutError("x"), socket.gaierror("x"),
|
||||
urllib.error.URLError("x")):
|
||||
assert isinstance(exc, _TOLERATED_UPDATE_ERRORS)
|
||||
|
||||
def test_logic_errors_are_not_tolerated(self):
|
||||
for exc in (ValueError("x"), KeyError("x"), AttributeError("x"), TypeError("x")):
|
||||
assert not isinstance(exc, _TOLERATED_UPDATE_ERRORS)
|
||||
|
||||
|
||||
class TestSizeParsing:
|
||||
def test_parse_size_token_ok(self):
|
||||
assert parse_size_token(" 128X32 ") == (128, 32)
|
||||
|
||||
def test_parse_size_token_rejects_garbage(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_size_token("128xabc")
|
||||
with pytest.raises(ValueError):
|
||||
parse_size_token("128-32")
|
||||
|
||||
def test_rejects_non_positive_dimensions(self):
|
||||
for bad in ("0x32", "-64x32", "64x0", "64x-1"):
|
||||
with pytest.raises(ValueError):
|
||||
parse_size_token(bad)
|
||||
with pytest.raises(ValueError):
|
||||
coerce_sizes([[0, 32]])
|
||||
with pytest.raises(ValueError):
|
||||
coerce_sizes("64x-1")
|
||||
|
||||
def test_coerce_sizes_from_string_and_pairs(self):
|
||||
assert coerce_sizes("8x16,64x64") == [(8, 16), (64, 64)]
|
||||
assert coerce_sizes([[8, 16], (64, 64)]) == [(8, 16), (64, 64)]
|
||||
assert coerce_sizes(None) is None
|
||||
assert coerce_sizes("") is None
|
||||
|
||||
def test_resolve_precedence_env_then_spec_then_default(self, monkeypatch):
|
||||
monkeypatch.delenv("LEDMATRIX_TEST_SIZES", raising=False)
|
||||
assert resolve_test_sizes(None) == list(DEFAULT_TEST_SIZES)
|
||||
assert resolve_test_sizes([[8, 16]]) == [(8, 16)]
|
||||
monkeypatch.setenv("LEDMATRIX_TEST_SIZES", "5x5")
|
||||
# env wins over a per-plugin spec
|
||||
assert resolve_test_sizes([[8, 16]]) == [(5, 5)]
|
||||
|
||||
|
||||
class TestCompareImages:
|
||||
def test_identical_images_match(self):
|
||||
a = Image.new("RGB", (16, 16), (10, 20, 30))
|
||||
b = a.copy()
|
||||
ok, diff_pixels, max_delta = compare_images(a, b)
|
||||
assert ok and diff_pixels == 0 and max_delta == 0
|
||||
|
||||
def test_different_images_fail_at_zero_tolerance(self):
|
||||
a = Image.new("RGB", (16, 16), (0, 0, 0))
|
||||
b = a.copy()
|
||||
b.putpixel((1, 1), (255, 255, 255))
|
||||
ok, diff_pixels, max_delta = compare_images(a, b)
|
||||
assert not ok and diff_pixels == 1 and max_delta == 255
|
||||
|
||||
def test_tolerance_absorbs_small_noise(self):
|
||||
a = Image.new("RGB", (16, 16), (100, 100, 100))
|
||||
b = a.copy()
|
||||
b.putpixel((2, 2), (103, 100, 100)) # delta 3
|
||||
ok, _, max_delta = compare_images(a, b, max_delta=5, max_diff_pixels=0)
|
||||
assert ok and max_delta == 3
|
||||
|
||||
def test_size_mismatch_fails(self):
|
||||
a = Image.new("RGB", (16, 16))
|
||||
b = Image.new("RGB", (32, 16))
|
||||
ok, _, _ = compare_images(a, b)
|
||||
assert not ok
|
||||
|
||||
|
||||
class TestListModes:
|
||||
def test_instance_modes_take_precedence(self):
|
||||
inst = type("P", (), {"modes": ["a", "b"]})()
|
||||
assert list_modes(inst, {"display_modes": ["x"]}, "pid") == ["a", "b"]
|
||||
|
||||
def test_falls_back_to_manifest_display_modes(self):
|
||||
inst = type("P", (), {})()
|
||||
assert list_modes(inst, {"display_modes": ["x", "y"]}, "pid") == ["x", "y"]
|
||||
|
||||
def test_falls_back_to_plugin_id(self):
|
||||
inst = type("P", (), {})()
|
||||
assert list_modes(inst, {}, "pid") == ["pid"]
|
||||
115
test/plugins/test_plugin_matrix.py
Normal file
115
test/plugins/test_plugin_matrix.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Cross-size / cross-screen plugin safety test.
|
||||
|
||||
For every discovered plugin, render every declared screen at every supported
|
||||
matrix size and assert it: loads, renders without crashing, stays within the
|
||||
panel bounds, and — for plugins that ship golden images — matches them.
|
||||
|
||||
Plugin discovery (first match wins):
|
||||
- $LEDMATRIX_PLUGINS_DIR (os.pathsep-separated list of dirs), else
|
||||
- <project_root>/plugin-repos and <project_root>/plugins
|
||||
|
||||
A plugin opts into golden-image checks by adding test/golden/<WxH>/<mode>.png
|
||||
(and usually test/harness.json for deterministic config / mock data / time).
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.testing.harness import (
|
||||
render_plugin_matrix, compare_to_goldens,
|
||||
)
|
||||
from src.plugin_system.testing.loading import load_config_defaults, load_harness_spec
|
||||
from src.plugin_system.testing.sizes import resolve_test_sizes
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Set LEDMATRIX_REQUIRE_PLUGINS=1 in any CI/hardware pipeline where plugins are
|
||||
# expected to be present, so a discovery drift (empty search path) fails loudly
|
||||
# instead of silently skipping and losing this safety signal.
|
||||
_REQUIRE_PLUGINS = os.environ.get("LEDMATRIX_REQUIRE_PLUGINS") == "1"
|
||||
|
||||
|
||||
def _plugin_search_dirs() -> List[Path]:
|
||||
env = os.environ.get("LEDMATRIX_PLUGINS_DIR")
|
||||
if env:
|
||||
return [Path(p) for p in env.split(os.pathsep) if p]
|
||||
return [PROJECT_ROOT / "plugin-repos", PROJECT_ROOT / "plugins"]
|
||||
|
||||
|
||||
def _discover() -> Dict[str, Path]:
|
||||
"""Map plugin_id -> plugin_dir for all plugins on the search path."""
|
||||
found: Dict[str, Path] = {}
|
||||
for base in _plugin_search_dirs():
|
||||
if not base.exists():
|
||||
continue
|
||||
for child in sorted(base.iterdir()):
|
||||
if (child / "manifest.json").exists() and child.name not in found:
|
||||
found[child.name] = child
|
||||
return found
|
||||
|
||||
|
||||
_PLUGINS = _discover()
|
||||
|
||||
|
||||
@pytest.mark.plugin
|
||||
def test_plugins_were_discovered() -> None:
|
||||
"""Guard against silently skipping the whole matrix when discovery drifts.
|
||||
|
||||
Local dev and the plugin-less core CI legitimately have no plugins, so we
|
||||
skip there; but when LEDMATRIX_REQUIRE_PLUGINS=1 an empty search path is a
|
||||
hard failure rather than a green no-op.
|
||||
"""
|
||||
if _PLUGINS:
|
||||
return
|
||||
search = [str(p) for p in _plugin_search_dirs()]
|
||||
if _REQUIRE_PLUGINS:
|
||||
pytest.fail(
|
||||
"LEDMATRIX_REQUIRE_PLUGINS=1 but no plugins were discovered on the "
|
||||
f"search path: {search}"
|
||||
)
|
||||
pytest.skip(f"no plugins found on the search path: {search}")
|
||||
|
||||
|
||||
@pytest.mark.plugin
|
||||
@pytest.mark.skipif(not _PLUGINS, reason="no plugins found on the search path")
|
||||
@pytest.mark.parametrize("plugin_id", sorted(_PLUGINS))
|
||||
def test_plugin_renders_across_sizes_and_screens(plugin_id: str) -> None:
|
||||
plugin_dir = _PLUGINS[plugin_id]
|
||||
spec = load_harness_spec(plugin_dir)
|
||||
|
||||
config = {"enabled": True}
|
||||
config.update(load_config_defaults(plugin_dir))
|
||||
config.update(spec.get("config", {}))
|
||||
|
||||
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
|
||||
# plugin's own harness.json "sizes", else the default representative sample.
|
||||
sizes = resolve_test_sizes(spec.get("sizes"))
|
||||
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
mock_data=spec.get("mock_data_contents", {}),
|
||||
sizes=sizes,
|
||||
run_update=not spec.get("skip_update", False),
|
||||
freeze_time=spec.get("freeze_time"),
|
||||
)
|
||||
compare_to_goldens(results, plugin_dir / "test" / "golden")
|
||||
|
||||
failures = []
|
||||
for r in results:
|
||||
if r.error is not None:
|
||||
failures.append(f"{r.size_label} {r.mode}: crashed: {r.error}")
|
||||
elif r.overflow is not None:
|
||||
failures.append(f"{r.size_label} {r.mode}: overflow past panel bbox={r.overflow}")
|
||||
elif r.golden_checked and r.golden_ok is False:
|
||||
failures.append(
|
||||
f"{r.size_label} {r.mode}: golden drift {r.golden_diff_pixels}px "
|
||||
f"(max Δ={r.golden_max_delta})"
|
||||
)
|
||||
|
||||
assert not failures, f"{plugin_id} failed:\n " + "\n ".join(failures)
|
||||
@@ -167,6 +167,53 @@ class TestDisplayControllerLivePriority:
|
||||
assert controller.current_display_mode == "test_plugin_live"
|
||||
assert controller.force_change is True
|
||||
|
||||
def test_live_priority_resume_continues_rotation(self, test_display_controller):
|
||||
"""Regression: when live priority ends, rotation resumes where it was
|
||||
interrupted, not after the live plugin's mode.
|
||||
|
||||
Without the fix, _apply_live_priority left current_mode_index pointing at
|
||||
the live plugin's slot, so the next rotation step skipped every mode
|
||||
between the interrupted position and the live plugin (e.g. elections,
|
||||
which sits just before a flights plugin in the order)."""
|
||||
controller = test_display_controller
|
||||
controller.available_modes = [
|
||||
"weather", "forecast", "almanac", "election_ticker", "flight_live"
|
||||
]
|
||||
# Rotation is about to show the 3rd mode (index 2).
|
||||
controller.current_mode_index = 2
|
||||
controller.current_display_mode = "almanac"
|
||||
controller._live_resume_index = None
|
||||
|
||||
# Live priority (e.g. planes overhead) preempts -> flight_live (index 4).
|
||||
controller._apply_live_priority("flight_live")
|
||||
assert controller.current_display_mode == "flight_live"
|
||||
assert controller.current_mode_index == 4
|
||||
assert controller._live_resume_index == 2 # saved rotation position
|
||||
|
||||
# Re-checks while the hold continues must not move the saved position.
|
||||
controller._apply_live_priority("flight_live")
|
||||
assert controller._live_resume_index == 2
|
||||
|
||||
# Live priority ends -> resume at the saved index (almanac), so the next
|
||||
# rotation step lands on election_ticker (index 3) rather than skipping it.
|
||||
controller._apply_live_priority(None)
|
||||
assert controller.current_mode_index == 2
|
||||
assert controller.current_display_mode == "almanac"
|
||||
assert controller._live_resume_index is None
|
||||
|
||||
def test_live_priority_no_resume_when_idle(self, test_display_controller):
|
||||
"""No saved position + no live content is a no-op (normal rotation)."""
|
||||
controller = test_display_controller
|
||||
controller.available_modes = ["a", "b", "c"]
|
||||
controller.current_mode_index = 1
|
||||
controller.current_display_mode = "b"
|
||||
controller._live_resume_index = None
|
||||
|
||||
controller._apply_live_priority(None)
|
||||
|
||||
assert controller.current_mode_index == 1
|
||||
assert controller.current_display_mode == "b"
|
||||
|
||||
|
||||
class TestDisplayControllerDynamicDuration:
|
||||
"""Test dynamic duration handling."""
|
||||
@@ -229,18 +276,20 @@ class TestDisplayControllerSchedule:
|
||||
def test_inactive_hours(self, test_display_controller):
|
||||
"""Test inactive hours check."""
|
||||
controller = test_display_controller
|
||||
# Inject schedule directly into self.config (what _check_schedule actually reads)
|
||||
# and reset the minute gate so the cached result from any prior call is cleared.
|
||||
controller.config['schedule'] = {
|
||||
"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00",
|
||||
}
|
||||
controller._schedule_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
with patch('src.display_controller.datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
||||
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
|
||||
mock_datetime.strptime = datetime.strptime
|
||||
|
||||
schedule_config = {
|
||||
"schedule": {
|
||||
"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00"
|
||||
}
|
||||
}
|
||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is False
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is False
|
||||
|
||||
322
test/test_display_controller_optimizations.py
Normal file
322
test/test_display_controller_optimizations.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Tests for the three display_controller.py optimizations:
|
||||
|
||||
Opt #1 — inspect.signature() caching per plugin_id
|
||||
Opt #2 — pre-cached config values (_normal_brightness, _scroll_speed)
|
||||
Opt #3 — schedule minute-gate (_check_schedule, _check_dim_schedule)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller):
|
||||
"""Return a ready DisplayController from the existing suite fixture."""
|
||||
return test_display_controller
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opt #1 — signature cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignatureCache:
|
||||
"""inspect.signature() should be called at most once per plugin_id."""
|
||||
|
||||
class _PluginWithMode:
|
||||
"""Real class whose display() accepts display_mode — inspectable by signature."""
|
||||
plugin_id = "mode_plugin"
|
||||
def display(self, display_mode=None, force_clear=False):
|
||||
return True
|
||||
|
||||
class _PluginNoMode:
|
||||
"""Real class whose display() does NOT accept display_mode."""
|
||||
plugin_id = "no_mode_plugin"
|
||||
def display(self, force_clear=False):
|
||||
return True
|
||||
|
||||
def test_cache_starts_empty(self, controller):
|
||||
assert controller._plugin_accepts_display_mode == {}
|
||||
|
||||
def test_signature_computed_and_cached(self, controller):
|
||||
"""After the first cache population, the dict holds a bool and stays unchanged
|
||||
if queried again without explicitly deleting the key."""
|
||||
import inspect as _inspect
|
||||
plugin = self._PluginNoMode()
|
||||
key = "sig_test"
|
||||
if key not in controller._plugin_accepts_display_mode:
|
||||
controller._plugin_accepts_display_mode[key] = (
|
||||
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||
)
|
||||
original = controller._plugin_accepts_display_mode[key]
|
||||
|
||||
# Accessing cache again should not change the value
|
||||
second = controller._plugin_accepts_display_mode[key]
|
||||
assert second == original
|
||||
|
||||
def test_cache_stores_false_for_no_display_mode(self, controller):
|
||||
"""Plugin whose display() doesn't accept display_mode → cached False."""
|
||||
import inspect as _inspect
|
||||
plugin = self._PluginNoMode()
|
||||
controller._plugin_accepts_display_mode["no_mode_plugin"] = (
|
||||
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||
)
|
||||
assert controller._plugin_accepts_display_mode["no_mode_plugin"] is False
|
||||
|
||||
def test_cache_stores_true_for_display_mode(self, controller):
|
||||
"""Plugin whose display() accepts display_mode → cached True."""
|
||||
import inspect as _inspect
|
||||
plugin = self._PluginWithMode()
|
||||
controller._plugin_accepts_display_mode["mode_plugin"] = (
|
||||
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||
)
|
||||
assert controller._plugin_accepts_display_mode["mode_plugin"] is True
|
||||
|
||||
def test_cache_cleared_on_plugin_reload(self, controller):
|
||||
"""Populating plugin_modes for an id that's already cached must clear the entry."""
|
||||
plugin = MagicMock()
|
||||
controller._plugin_accepts_display_mode["reload_plugin"] = False
|
||||
|
||||
# Simulate the plugin_modes population code path (as in __init__)
|
||||
plugin_id = "reload_plugin"
|
||||
controller.plugin_modes["reload_plugin"] = plugin
|
||||
if hasattr(controller, "_plugin_accepts_display_mode"):
|
||||
controller._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||
|
||||
assert "reload_plugin" not in controller._plugin_accepts_display_mode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opt #2 — cached config values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCachedConfigValues:
|
||||
"""_normal_brightness and _scroll_speed are populated from config at init."""
|
||||
|
||||
def test_normal_brightness_cached(self, controller):
|
||||
"""_normal_brightness must equal what the config says."""
|
||||
expected = (
|
||||
controller.config
|
||||
.get("display", {})
|
||||
.get("hardware", {})
|
||||
.get("brightness", 90)
|
||||
)
|
||||
assert controller._normal_brightness == expected
|
||||
|
||||
def test_scroll_speed_cached(self, controller):
|
||||
"""_scroll_speed must equal what the config says."""
|
||||
expected = (
|
||||
controller.config
|
||||
.get("display", {})
|
||||
.get("vegas_scroll", {})
|
||||
.get("scroll_speed", 75)
|
||||
)
|
||||
assert controller._scroll_speed == expected
|
||||
|
||||
def test_current_brightness_uses_cached_value(self, controller):
|
||||
"""current_brightness is initialised from _normal_brightness."""
|
||||
assert controller.current_brightness == controller._normal_brightness
|
||||
|
||||
def test_cached_target_brightness_init(self, controller):
|
||||
"""_cached_target_brightness starts equal to _normal_brightness."""
|
||||
assert controller._cached_target_brightness == controller._normal_brightness
|
||||
|
||||
def test_normal_brightness_default_is_90(self, controller):
|
||||
"""If config has no brightness key the default is 90."""
|
||||
controller.config = {}
|
||||
controller._normal_brightness = (
|
||||
controller.config.get("display", {})
|
||||
.get("hardware", {})
|
||||
.get("brightness", 90)
|
||||
)
|
||||
assert controller._normal_brightness == 90
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opt #3 — schedule minute-gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScheduleMinuteGate:
|
||||
"""_check_schedule and _check_dim_schedule skip re-evaluation within the same minute."""
|
||||
|
||||
# ── _check_schedule ──────────────────────────────────────────────────────
|
||||
|
||||
def test_schedule_checked_minute_starts_none(self, controller):
|
||||
assert controller._schedule_checked_minute is None
|
||||
|
||||
def test_first_call_sets_checked_minute(self, controller):
|
||||
"""After the first real evaluation the minute key is stored."""
|
||||
controller.config["schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "00:00",
|
||||
"end_time": "23:59",
|
||||
}
|
||||
controller._schedule_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
controller._check_schedule()
|
||||
assert controller._schedule_checked_minute is not None
|
||||
|
||||
def test_second_call_same_minute_does_not_re_evaluate(self, controller):
|
||||
"""A second call with the same (hour, minute) returns without changing state."""
|
||||
controller.config["schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "00:00",
|
||||
"end_time": "23:59",
|
||||
}
|
||||
controller._tz = None
|
||||
controller._schedule_checked_minute = None
|
||||
|
||||
# First call — evaluates and marks as active (whole-day window)
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is True
|
||||
first_minute_key = controller._schedule_checked_minute
|
||||
|
||||
# Force is_display_active to False so we can tell if it gets re-evaluated
|
||||
controller.is_display_active = False
|
||||
|
||||
# Second call within the same minute — gate fires, is_display_active unchanged
|
||||
controller._schedule_checked_minute = first_minute_key # same minute
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is False, (
|
||||
"Second call in same minute should return immediately without re-evaluation"
|
||||
)
|
||||
|
||||
def test_new_minute_forces_re_evaluation(self, controller):
|
||||
"""A different (hour, minute) key causes a full re-evaluation."""
|
||||
controller.config["schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "00:00",
|
||||
"end_time": "23:59",
|
||||
}
|
||||
controller._tz = None
|
||||
|
||||
# Plant a stale minute key from yesterday
|
||||
controller._schedule_checked_minute = (-1, -1)
|
||||
controller.is_display_active = False # wrong value to be corrected
|
||||
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is True, (
|
||||
"A new minute key should trigger re-evaluation and correct is_display_active"
|
||||
)
|
||||
|
||||
def test_gate_skipped_when_schedule_disabled(self, controller):
|
||||
"""When schedule.enabled=False the method returns before reaching the gate."""
|
||||
controller.config["schedule"] = {"enabled": False}
|
||||
controller._schedule_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
controller._check_schedule()
|
||||
# The early-return path doesn't set the minute key
|
||||
assert controller._schedule_checked_minute is None
|
||||
|
||||
# ── _check_dim_schedule ──────────────────────────────────────────────────
|
||||
|
||||
def test_dim_checked_minute_starts_none(self, controller):
|
||||
assert controller._dim_checked_minute is None
|
||||
|
||||
def test_first_dim_call_sets_checked_minute(self, controller):
|
||||
"""First call with dim schedule enabled stores the minute key."""
|
||||
controller.config["dim_schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "22:00",
|
||||
"end_time": "06:00",
|
||||
}
|
||||
controller.is_display_active = True
|
||||
controller._dim_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
controller._check_dim_schedule()
|
||||
assert controller._dim_checked_minute is not None
|
||||
|
||||
def test_dim_second_call_returns_cached_brightness(self, controller):
|
||||
"""Second call with same minute returns _cached_target_brightness immediately."""
|
||||
controller.config["dim_schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "22:00",
|
||||
"end_time": "06:00",
|
||||
}
|
||||
controller.is_display_active = True
|
||||
controller._dim_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
# First call stores the result
|
||||
first_result = controller._check_dim_schedule()
|
||||
assert controller._cached_target_brightness == first_result
|
||||
minute_key = controller._dim_checked_minute
|
||||
|
||||
# Corrupt cached value to something recognisable
|
||||
controller._cached_target_brightness = 42
|
||||
|
||||
# Second call in same minute — must return the cached 42
|
||||
controller._dim_checked_minute = minute_key
|
||||
second_result = controller._check_dim_schedule()
|
||||
assert second_result == 42, (
|
||||
"Same-minute call must return cached brightness, not re-compute"
|
||||
)
|
||||
|
||||
def test_dim_gate_skipped_when_display_off(self, controller):
|
||||
"""When display is off the method exits before the minute gate."""
|
||||
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
|
||||
controller.is_display_active = False
|
||||
controller._dim_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
controller._check_dim_schedule()
|
||||
# Early-exit path does not set the minute key
|
||||
assert controller._dim_checked_minute is None
|
||||
|
||||
def test_dim_cached_target_brightness_updated_after_full_evaluation(self, controller):
|
||||
"""After a full evaluation _cached_target_brightness reflects the result."""
|
||||
controller.config["dim_schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "22:00",
|
||||
"end_time": "06:00",
|
||||
}
|
||||
controller.is_display_active = True
|
||||
controller._dim_checked_minute = None # force full re-evaluation
|
||||
controller._tz = None
|
||||
|
||||
result = controller._check_dim_schedule()
|
||||
assert controller._cached_target_brightness == result
|
||||
|
||||
# ── timezone lazy init ───────────────────────────────────────────────────
|
||||
|
||||
def test_tz_starts_none(self, controller):
|
||||
assert controller._tz is None
|
||||
|
||||
def test_tz_lazily_initialised_on_first_schedule_check(self, controller):
|
||||
"""_tz is None until _check_schedule or _check_dim_schedule is called."""
|
||||
controller.config["schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "00:00",
|
||||
"end_time": "23:59",
|
||||
}
|
||||
controller._tz = None
|
||||
controller._schedule_checked_minute = None
|
||||
|
||||
controller._check_schedule()
|
||||
assert controller._tz is not None
|
||||
|
||||
def test_tz_shared_between_schedule_and_dim(self, controller):
|
||||
"""Both methods use the same cached _tz instance."""
|
||||
controller.config["schedule"] = {"enabled": True, "start_time": "00:00", "end_time": "23:59"}
|
||||
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
|
||||
controller.is_display_active = True
|
||||
controller._tz = None
|
||||
controller._schedule_checked_minute = None
|
||||
controller._dim_checked_minute = None
|
||||
|
||||
controller._check_schedule()
|
||||
tz_after_schedule = controller._tz
|
||||
|
||||
controller._check_dim_schedule()
|
||||
assert controller._tz is tz_after_schedule, (
|
||||
"_check_dim_schedule should reuse the _tz set by _check_schedule"
|
||||
)
|
||||
@@ -58,19 +58,15 @@ class TestGitInfoCache(unittest.TestCase):
|
||||
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
|
||||
def _fake_subprocess_run(self, *args, **kwargs):
|
||||
# Return different dummy values depending on which git subcommand
|
||||
# was invoked so the code paths that parse output all succeed.
|
||||
# _get_local_git_info now reads branch and remote_url directly from
|
||||
# .git/HEAD and .git/config (no subprocess) and uses a single
|
||||
# ``git log --format=%H%n%cI`` call that returns SHA on line 1 and
|
||||
# ISO date on line 2. Adjust the fake accordingly.
|
||||
cmd = args[0]
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
if "rev-parse" in cmd and "HEAD" in cmd and "--abbrev-ref" not in cmd:
|
||||
result.stdout = "abcdef1234567890\n"
|
||||
elif "--abbrev-ref" in cmd:
|
||||
result.stdout = "main\n"
|
||||
elif "config" in cmd:
|
||||
result.stdout = "https://example.com/repo.git\n"
|
||||
elif "log" in cmd:
|
||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
||||
if "log" in cmd:
|
||||
result.stdout = "abcdef1234567890\n2026-04-08T12:00:00+00:00\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
return result
|
||||
@@ -84,7 +80,8 @@ class TestGitInfoCache(unittest.TestCase):
|
||||
self.assertIsNotNone(first)
|
||||
self.assertEqual(first["short_sha"], "abcdef1")
|
||||
calls_after_first = mock_run.call_count
|
||||
self.assertEqual(calls_after_first, 4)
|
||||
# Production code now uses a single ``git log`` call.
|
||||
self.assertEqual(calls_after_first, 1)
|
||||
|
||||
# Second call with unchanged HEAD: zero new subprocess calls.
|
||||
second = self.sm._get_local_git_info(self.plugin_path)
|
||||
@@ -105,7 +102,8 @@ class TestGitInfoCache(unittest.TestCase):
|
||||
os.utime(head, (new_time, new_time))
|
||||
|
||||
self.sm._get_local_git_info(self.plugin_path)
|
||||
self.assertEqual(mock_run.call_count, calls_after_first + 4)
|
||||
# One new ``git log`` call after cache invalidation.
|
||||
self.assertEqual(mock_run.call_count, calls_after_first + 1)
|
||||
|
||||
def test_no_git_directory_returns_none(self):
|
||||
non_git = self.plugins_dir / "no_git"
|
||||
@@ -192,14 +190,11 @@ class TestGitInfoCache(unittest.TestCase):
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
cmd = args[0]
|
||||
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
|
||||
result.stdout = branch_file.read_text().strip() + "\n"
|
||||
elif "--abbrev-ref" in cmd:
|
||||
result.stdout = "main\n"
|
||||
elif "config" in cmd:
|
||||
result.stdout = "https://example.com/repo.git\n"
|
||||
elif "log" in cmd:
|
||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
||||
# Production code now uses a single ``git log --format=%H%n%cI``.
|
||||
# Branch and remote_url are read directly from .git/HEAD/.git/config.
|
||||
if "log" in cmd:
|
||||
sha = branch_file.read_text().strip()
|
||||
result.stdout = f"{sha}\n2026-04-08T12:00:00+00:00\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
return result
|
||||
|
||||
@@ -617,7 +617,8 @@ class TestDottedKeyNormalization:
|
||||
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
||||
}
|
||||
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||
schema_mgr.validate_config_against_schema.return_value = []
|
||||
# Must be a (bool, list) tuple: the endpoint does is_valid, errors = validate_config_against_schema(...)
|
||||
schema_mgr.validate_config_against_schema.return_value = (True, [])
|
||||
api_v3.schema_manager = schema_mgr
|
||||
|
||||
request_data = {
|
||||
@@ -679,7 +680,7 @@ class TestDottedKeyNormalization:
|
||||
'leagues': {'eng.1': {'favorite_teams': []}},
|
||||
}
|
||||
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||
schema_mgr.validate_config_against_schema.return_value = []
|
||||
schema_mgr.validate_config_against_schema.return_value = (True, [])
|
||||
api_v3.schema_manager = schema_mgr
|
||||
|
||||
request_data = {
|
||||
|
||||
@@ -224,20 +224,14 @@ class TestStateReconciliation(unittest.TestCase):
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
||||
|
||||
# Mock save_config to track calls
|
||||
saved_configs = []
|
||||
def save_config(config):
|
||||
saved_configs.append(config)
|
||||
|
||||
self.config_manager.save_config = save_config
|
||||
|
||||
# Run reconciliation
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# Verify fix was attempted
|
||||
|
||||
# config.json is the source of truth for enabled state. The fix syncs
|
||||
# the state manager to match config (config says True → state set True),
|
||||
# rather than overwriting the config with the stale state value.
|
||||
self.assertEqual(len(result.inconsistencies_fixed), 1)
|
||||
self.assertEqual(len(saved_configs), 1)
|
||||
self.assertEqual(saved_configs[0]["plugin1"]["enabled"], False)
|
||||
self.state_manager.set_plugin_enabled.assert_called_once_with("plugin1", True)
|
||||
|
||||
def test_multiple_inconsistencies(self):
|
||||
"""Test reconciliation with multiple inconsistencies."""
|
||||
|
||||
76
test/web_interface/test_systemctl_sudoers_alignment.py
Normal file
76
test/web_interface/test_systemctl_sudoers_alignment.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Guards that every privileged systemctl call the web interface makes is
|
||||
covered by a passwordless-sudo grant in configure_web_sudo.sh.
|
||||
|
||||
The web interface runs headless (no TTY), so any `sudo` call that is not
|
||||
matched by a NOPASSWD rule in /etc/sudoers.d/ledmatrix_web falls back to a
|
||||
password prompt and fails with:
|
||||
|
||||
sudo: a terminal is required to read the password
|
||||
|
||||
sudo matches the command line by exact string, so `systemctl start ledmatrix`
|
||||
and `systemctl start ledmatrix.service` are NOT interchangeable. This test
|
||||
parses both the production blueprint and the sudoers-generator script and
|
||||
asserts the (verb, unit) pairs line up, catching the suffix-mismatch class of
|
||||
bug before it ships.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
|
||||
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||
|
||||
|
||||
def _sudo_systemctl_calls(source: str) -> set[tuple[str, str]]:
|
||||
"""Return (verb, unit) for every list literal beginning with
|
||||
['sudo', 'systemctl', ...] passed to a subprocess call in the source."""
|
||||
calls: set[tuple[str, str]] = set()
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if not isinstance(node, ast.List):
|
||||
continue
|
||||
elts = node.elts
|
||||
if len(elts) < 4:
|
||||
continue
|
||||
if not all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in elts[:4]):
|
||||
continue
|
||||
if elts[0].value == "sudo" and elts[1].value == "systemctl":
|
||||
calls.add((elts[2].value, elts[3].value))
|
||||
return calls
|
||||
|
||||
|
||||
def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
|
||||
"""Return (verb, unit) for each `$SYSTEMCTL_PATH <verb> <unit>` NOPASSWD
|
||||
grant emitted by the sudoers-generator script."""
|
||||
rules: set[tuple[str, str]] = set()
|
||||
for match in re.finditer(r"\$SYSTEMCTL_PATH\s+(\S+)\s+(\S+)", script):
|
||||
verb, unit = match.group(1), match.group(2).rstrip('"')
|
||||
rules.add((verb, unit))
|
||||
return rules
|
||||
|
||||
|
||||
def test_every_sudo_systemctl_call_is_granted() -> None:
|
||||
calls = _sudo_systemctl_calls(API_V3.read_text())
|
||||
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
||||
|
||||
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
||||
|
||||
uncovered = {c for c in calls if c not in rules}
|
||||
assert not uncovered, (
|
||||
"These sudo systemctl calls have no matching NOPASSWD grant in "
|
||||
"configure_web_sudo.sh; they will fail headless with "
|
||||
"'sudo: a terminal is required to read the password': "
|
||||
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(uncovered))
|
||||
)
|
||||
|
||||
|
||||
def test_units_are_fully_qualified() -> None:
|
||||
"""Privileged systemctl calls must name the unit as <name>.service so they
|
||||
match the sudoers grants, which use the fully-qualified unit name."""
|
||||
calls = _sudo_systemctl_calls(API_V3.read_text())
|
||||
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
||||
assert not unqualified, (
|
||||
"sudo systemctl calls must use fully-qualified .service unit names: "
|
||||
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(unqualified))
|
||||
)
|
||||
@@ -2,8 +2,11 @@ from flask import Flask, request, redirect, url_for, jsonify, Response, send_fro
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
@@ -22,6 +25,9 @@ from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.plugin_system.operation_history import OperationHistory
|
||||
from src.plugin_system.health_monitor import PluginHealthMonitor
|
||||
|
||||
_JOURNALCTL = shutil.which('journalctl')
|
||||
_SYSTEMCTL = shutil.which('systemctl')
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.urandom(24)
|
||||
@@ -204,24 +210,12 @@ def serve_plugin_asset(plugin_id, filename):
|
||||
# Use send_from_directory to serve the file
|
||||
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
|
||||
|
||||
except Exception as e:
|
||||
# Log the exception with full traceback server-side
|
||||
import traceback
|
||||
except Exception:
|
||||
app.logger.exception('Error serving plugin asset file')
|
||||
|
||||
# Return generic error message to client (avoid leaking internal details)
|
||||
# Only include detailed error information when in debug mode
|
||||
if app.debug:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
'traceback': traceback.format_exc()
|
||||
}), 500
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Internal server error'
|
||||
}), 500
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Internal server error'
|
||||
}), 500
|
||||
|
||||
# Prime psutil CPU measurement once at startup so interval=None returns a real value
|
||||
try:
|
||||
@@ -342,35 +336,25 @@ def not_found_error(error):
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""Handle 500 errors."""
|
||||
import traceback
|
||||
error_details = traceback.format_exc()
|
||||
|
||||
# Log the error
|
||||
import logging
|
||||
logger = logging.getLogger('web_interface')
|
||||
logger.error(f"Internal server error: {error}", exc_info=True)
|
||||
|
||||
# Return user-friendly error (hide internal details in production)
|
||||
logger.error("Internal server error", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_code': 'INTERNAL_ERROR',
|
||||
'message': 'An internal error occurred',
|
||||
'details': error_details if app.debug else None
|
||||
'message': 'An internal error occurred; see logs for details',
|
||||
}), 500
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_exception(error):
|
||||
"""Handle all unhandled exceptions."""
|
||||
import traceback
|
||||
import logging
|
||||
logger = logging.getLogger('web_interface')
|
||||
logger.error(f"Unhandled exception: {error}", exc_info=True)
|
||||
|
||||
logger.error("Unhandled exception", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_code': 'UNKNOWN_ERROR',
|
||||
'message': str(error) if app.debug else 'An error occurred',
|
||||
'details': traceback.format_exc() if app.debug else None
|
||||
'message': 'An error occurred; see logs for details',
|
||||
}), 500
|
||||
|
||||
# Captive portal redirect middleware
|
||||
@@ -407,6 +391,22 @@ def captive_portal_redirect():
|
||||
# Redirect to lightweight captive portal setup page (not the full UI)
|
||||
return redirect(url_for('pages_v3.captive_setup'), code=302)
|
||||
|
||||
# Append a content-version query param (file mtime) to every static URL so the
|
||||
# long-lived `immutable` cache (see add_security_headers below) is actually safe:
|
||||
# when a static file changes its URL changes, so browsers refetch it. Without
|
||||
# this, edited JS/CSS were served immutable under an unchanging URL and never
|
||||
# reached clients until a manual cache clear.
|
||||
@app.url_defaults
|
||||
def add_static_version(endpoint, values):
|
||||
if endpoint == 'static' and values.get('filename'):
|
||||
try:
|
||||
file_path = os.path.join(app.static_folder, values['filename'])
|
||||
values['v'] = int(os.path.getmtime(file_path))
|
||||
except OSError:
|
||||
# File missing (e.g. plugin asset not yet installed) — skip versioning.
|
||||
pass
|
||||
|
||||
|
||||
# Add security headers and caching to all responses
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
@@ -435,13 +435,53 @@ def add_security_headers(response):
|
||||
|
||||
return response
|
||||
|
||||
# SSE helper function
|
||||
def sse_response(generator_func):
|
||||
"""Helper to create SSE responses"""
|
||||
def generate():
|
||||
for data in generator_func():
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
return Response(generate(), mimetype='text/event-stream')
|
||||
class _StreamBroadcaster:
|
||||
"""Fan-out broadcaster: one background generator thread pushes to all SSE clients.
|
||||
|
||||
This means N browser tabs share one generator instead of each running their own,
|
||||
keeping PIL encodes / subprocess forks constant regardless of how many tabs are open.
|
||||
"""
|
||||
|
||||
def __init__(self, generator_factory):
|
||||
self._generator_factory = generator_factory
|
||||
self._clients: set = set()
|
||||
self._lock = threading.Lock()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
q: queue.Queue = queue.Queue(maxsize=5)
|
||||
with self._lock:
|
||||
self._clients.add(q)
|
||||
if not (self._thread and self._thread.is_alive()):
|
||||
self._thread = threading.Thread(target=self._broadcast, daemon=True)
|
||||
self._thread.start()
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue) -> None:
|
||||
with self._lock:
|
||||
self._clients.discard(q)
|
||||
|
||||
def _broadcast(self):
|
||||
for data in self._generator_factory():
|
||||
with self._lock:
|
||||
if not self._clients:
|
||||
# No subscribers — exit so the thread doesn't spin indefinitely.
|
||||
# subscribe() will restart it when a new client arrives.
|
||||
break
|
||||
for q in self._clients:
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except queue.Full:
|
||||
# Client is reading too slowly; drop the oldest item and
|
||||
# deliver the latest so the queue never stalls the client.
|
||||
try:
|
||||
q.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# System status generator for SSE
|
||||
def system_status_generator():
|
||||
@@ -472,12 +512,13 @@ def system_status_generator():
|
||||
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||
now = time.time()
|
||||
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
||||
try:
|
||||
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
if _SYSTEMCTL:
|
||||
try:
|
||||
result = subprocess.run([_SYSTEMCTL, 'is-active', 'ledmatrix'],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
app.logger.warning("systemctl status check failed: %s", e)
|
||||
_ledmatrix_service_cache['timestamp'] = now
|
||||
service_active = _ledmatrix_service_cache['active']
|
||||
|
||||
@@ -492,7 +533,8 @@ def system_status_generator():
|
||||
}
|
||||
yield status
|
||||
except Exception as e:
|
||||
yield {'error': str(e)}
|
||||
app.logger.error("SSE generator error", exc_info=True)
|
||||
yield {'error': 'An error occurred; see server logs'}
|
||||
time.sleep(10) # Update every 10 seconds (reduced frequency for better performance)
|
||||
|
||||
# Display preview generator for SSE
|
||||
@@ -555,7 +597,8 @@ def display_preview_generator():
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
yield {'error': str(e)}
|
||||
app.logger.error("SSE generator error", exc_info=True)
|
||||
yield {'error': 'An error occurred; see server logs'}
|
||||
|
||||
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
|
||||
|
||||
@@ -567,8 +610,13 @@ def logs_generator():
|
||||
# Get recent logs from journalctl (simplified version)
|
||||
# Note: User should be in systemd-journal group to read logs without sudo
|
||||
try:
|
||||
if not _JOURNALCTL:
|
||||
yield {'timestamp': time.time(), 'logs': 'journalctl not found; cannot read logs'}
|
||||
time.sleep(60)
|
||||
continue
|
||||
result = subprocess.run(
|
||||
['journalctl', '-u', 'ledmatrix.service', '-n', '50', '--no-pager'],
|
||||
[_JOURNALCTL, '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
||||
'-n', '50', '--no-pager', '--output=short-iso'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
@@ -584,7 +632,7 @@ def logs_generator():
|
||||
# No logs available
|
||||
logs_data = {
|
||||
'timestamp': time.time(),
|
||||
'logs': 'No logs available from ledmatrix service'
|
||||
'logs': 'No logs available from ledmatrix or ledmatrix-web service'
|
||||
}
|
||||
yield logs_data
|
||||
else:
|
||||
@@ -598,36 +646,68 @@ def logs_generator():
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout - just skip this update
|
||||
pass
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
app.logger.error("Error running journalctl", exc_info=True)
|
||||
error_data = {
|
||||
'timestamp': time.time(),
|
||||
'logs': f'Error running journalctl: {str(e)}'
|
||||
'logs': 'Error running journalctl; see server logs'
|
||||
}
|
||||
yield error_data
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
app.logger.error("Unexpected error in logs generator", exc_info=True)
|
||||
error_data = {
|
||||
'timestamp': time.time(),
|
||||
'logs': f'Unexpected error in logs generator: {str(e)}'
|
||||
'logs': 'Unexpected error in logs generator; see server logs'
|
||||
}
|
||||
yield error_data
|
||||
|
||||
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
|
||||
|
||||
# One broadcaster per stream — shared across all SSE clients
|
||||
_stats_broadcaster = _StreamBroadcaster(system_status_generator)
|
||||
_display_broadcaster = _StreamBroadcaster(display_preview_generator)
|
||||
_logs_broadcaster = _StreamBroadcaster(logs_generator)
|
||||
|
||||
|
||||
def _sse_stream(broadcaster: _StreamBroadcaster) -> Response:
|
||||
"""Return a streaming SSE response backed by a shared broadcaster."""
|
||||
q = broadcaster.subscribe()
|
||||
|
||||
def generate():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = q.get(timeout=30)
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
except queue.Empty:
|
||||
# Send an SSE comment heartbeat to keep the connection alive
|
||||
# through proxies that close idle connections.
|
||||
yield ": heartbeat\n\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
finally:
|
||||
broadcaster.unsubscribe(q)
|
||||
|
||||
return Response(generate(), mimetype='text/event-stream')
|
||||
|
||||
|
||||
# SSE endpoints
|
||||
@app.route('/api/v3/stream/stats')
|
||||
def stream_stats():
|
||||
return sse_response(system_status_generator)
|
||||
return _sse_stream(_stats_broadcaster)
|
||||
|
||||
@app.route('/api/v3/stream/display')
|
||||
def stream_display():
|
||||
return sse_response(display_preview_generator)
|
||||
return _sse_stream(_display_broadcaster)
|
||||
|
||||
@app.route('/api/v3/stream/logs')
|
||||
def stream_logs():
|
||||
return sse_response(logs_generator)
|
||||
return _sse_stream(_logs_broadcaster)
|
||||
|
||||
# Exempt SSE streams from CSRF and add rate limiting
|
||||
# Exempt SSE streams from CSRF and apply a generous rate limit.
|
||||
# SSE connections are long-lived HTTP requests, not repeated API calls, so the
|
||||
# tight "20 per minute" default would be exhausted quickly on reconnects.
|
||||
if csrf:
|
||||
csrf.exempt(stream_stats)
|
||||
csrf.exempt(stream_display)
|
||||
@@ -635,9 +715,9 @@ if csrf:
|
||||
# Note: api_v3 blueprint is exempted above after registration
|
||||
|
||||
if limiter:
|
||||
limiter.limit("20 per minute")(stream_stats)
|
||||
limiter.limit("20 per minute")(stream_display)
|
||||
limiter.limit("20 per minute")(stream_logs)
|
||||
limiter.limit("200 per minute")(stream_stats)
|
||||
limiter.limit("200 per minute")(stream_display)
|
||||
limiter.limit("200 per minute")(stream_logs)
|
||||
|
||||
# Main route - redirect to v3 interface as default
|
||||
@app.route('/')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,14 @@ from flask import Blueprint, render_template, flash
|
||||
from markupsafe import escape
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Strict allowlists for URL-derived values used in path and script operations.
|
||||
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
||||
from src.web_interface.secret_helpers import mask_secret_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -84,10 +91,11 @@ def load_partial(partial_name):
|
||||
elif partial_name == 'operation-history':
|
||||
return _load_operation_history_partial()
|
||||
else:
|
||||
return f"Partial '{partial_name}' not found", 404
|
||||
return "Partial not found", 404
|
||||
|
||||
except Exception as e:
|
||||
return f"Error loading partial '{partial_name}': {str(e)}", 500
|
||||
logger.error("Error loading partial %s", partial_name, exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
@pages_v3.route('/partials/plugin-config/<plugin_id>')
|
||||
@@ -95,8 +103,102 @@ def load_plugin_config_partial(plugin_id):
|
||||
"""Load plugin configuration partial via HTMX - server-side rendered form"""
|
||||
try:
|
||||
return _load_plugin_config_partial(plugin_id)
|
||||
except Exception as e:
|
||||
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
|
||||
except Exception:
|
||||
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
||||
|
||||
|
||||
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
|
||||
def serve_plugin_web_ui(plugin_id, filename):
|
||||
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
|
||||
|
||||
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
||||
and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe.
|
||||
"""
|
||||
# Validate URL-derived values against strict allowlists before any path or
|
||||
# script operations.
|
||||
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
|
||||
if not _SAFE_WEB_UI_FILE_RE.match(filename):
|
||||
return 'Invalid filename', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
# os.path.basename() is the CodeQL-recognised path sanitizer used throughout
|
||||
# this codebase (see plugin_loader.py). Applying it here breaks the taint
|
||||
# chain even though the allowlist above already prevents path separators.
|
||||
safe_id = os.path.basename(plugin_id)
|
||||
safe_fn = os.path.basename(filename)
|
||||
if not safe_id or not safe_fn:
|
||||
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
if not pages_v3.plugin_manager:
|
||||
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
|
||||
|
||||
try:
|
||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||
|
||||
# Reconstruct from sanitised basename — CodeQL-approved pattern.
|
||||
_plugin_dir = (_plugins_base / safe_id).resolve()
|
||||
_plugin_dir.relative_to(_plugins_base) # containment guard
|
||||
|
||||
# Mirror PluginManager's ledmatrix- prefix fallback.
|
||||
if not _plugin_dir.exists():
|
||||
_alt_id = os.path.basename(f'ledmatrix-{safe_id}')
|
||||
_alt = (_plugins_base / _alt_id).resolve()
|
||||
try:
|
||||
_alt.relative_to(_plugins_base)
|
||||
_plugin_dir = _alt
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
web_ui_path = (_plugin_dir / 'web_ui' / safe_fn).resolve()
|
||||
web_ui_path.relative_to(_plugin_dir / 'web_ui') # second guard
|
||||
|
||||
if not web_ui_path.exists():
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
|
||||
fragment = web_ui_path.read_text(encoding='utf-8')
|
||||
|
||||
# json.dumps wraps the value in quotes. Replace HTML meta-chars with
|
||||
# their JS Unicode escape sequences so the value cannot close or escape
|
||||
# the enclosing <script> tag.
|
||||
# r'<' is the 6-char literal string <, which JavaScript
|
||||
# interprets as <. This is the standard JSON-in-HTML hardening pattern.
|
||||
safe_plugin_id_js = (
|
||||
json.dumps(safe_id)
|
||||
.replace('<', '\\u003c')
|
||||
.replace('>', '\\u003e')
|
||||
.replace('&', '\\u0026')
|
||||
)
|
||||
|
||||
page = (
|
||||
'<!DOCTYPE html>\n'
|
||||
'<html lang="en">\n'
|
||||
'<head>\n'
|
||||
'<meta charset="UTF-8">\n'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
||||
'<script>\n'
|
||||
# Inject plugin context before the fragment runs.
|
||||
# plugin_id is validated to [a-zA-Z0-9_-] above, so this is safe,
|
||||
# but we also Unicode-escape HTML meta-chars as defence in depth.
|
||||
f' window.PLUGIN_ID = {safe_plugin_id_js};\n'
|
||||
'</script>\n'
|
||||
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
|
||||
'<link rel="stylesheet" '
|
||||
'href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" '
|
||||
'crossorigin="anonymous">\n'
|
||||
'<style>body{margin:0;padding:0;background:#fff;}</style>\n'
|
||||
'</head>\n'
|
||||
'<body>\n'
|
||||
+ fragment +
|
||||
'\n</body>\n</html>'
|
||||
)
|
||||
return page, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
|
||||
except ValueError:
|
||||
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
||||
except Exception:
|
||||
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
||||
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
||||
|
||||
def _load_overview_partial():
|
||||
"""Load overview partial with system stats"""
|
||||
@@ -107,7 +209,8 @@ def _load_overview_partial():
|
||||
return render_template('v3/partials/overview.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_general_partial():
|
||||
"""Load general settings partial"""
|
||||
@@ -117,7 +220,8 @@ def _load_general_partial():
|
||||
return render_template('v3/partials/general.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_display_partial():
|
||||
"""Load display settings partial"""
|
||||
@@ -127,7 +231,8 @@ def _load_display_partial():
|
||||
return render_template('v3/partials/display.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_durations_partial():
|
||||
"""Load display durations partial"""
|
||||
@@ -137,7 +242,8 @@ def _load_durations_partial():
|
||||
return render_template('v3/partials/durations.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_schedule_partial():
|
||||
"""Load schedule settings partial"""
|
||||
@@ -153,7 +259,8 @@ def _load_schedule_partial():
|
||||
dim_schedule_config=dim_schedule_config,
|
||||
normal_brightness=normal_brightness)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
def _load_weather_partial():
|
||||
@@ -164,7 +271,8 @@ def _load_weather_partial():
|
||||
return render_template('v3/partials/weather.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_stocks_partial():
|
||||
"""Load stocks configuration partial"""
|
||||
@@ -174,7 +282,8 @@ def _load_stocks_partial():
|
||||
return render_template('v3/partials/stocks.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_plugins_partial():
|
||||
"""Load plugins management partial"""
|
||||
@@ -208,7 +317,7 @@ def _load_plugins_partial():
|
||||
plugin_info.update(fresh_manifest)
|
||||
except Exception as e:
|
||||
# If we can't read the fresh manifest, use the cached one
|
||||
print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}")
|
||||
logger.warning("Could not read fresh manifest for plugin: %s", plugin_id)
|
||||
|
||||
# Get enabled status from config (source of truth)
|
||||
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
||||
@@ -256,12 +365,13 @@ def _load_plugins_partial():
|
||||
'branch': branch
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error loading plugin data: {e}")
|
||||
logger.error("Error loading plugin data", exc_info=True)
|
||||
|
||||
return render_template('v3/partials/plugins.html',
|
||||
plugins=plugins_data)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_fonts_partial():
|
||||
"""Load fonts management partial"""
|
||||
@@ -271,14 +381,16 @@ def _load_fonts_partial():
|
||||
return render_template('v3/partials/fonts.html',
|
||||
fonts=fonts_data)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_logs_partial():
|
||||
"""Load logs viewer partial"""
|
||||
try:
|
||||
return render_template('v3/partials/logs.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_raw_json_partial():
|
||||
"""Load raw JSON editor partial"""
|
||||
@@ -295,14 +407,16 @@ def _load_raw_json_partial():
|
||||
main_config_path=pages_v3.config_manager.get_config_path(),
|
||||
secrets_config_path=pages_v3.config_manager.get_secrets_path())
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_backup_restore_partial():
|
||||
"""Load backup & restore partial."""
|
||||
try:
|
||||
return render_template('v3/partials/backup_restore.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
@pages_v3.route('/setup')
|
||||
def captive_setup():
|
||||
@@ -314,21 +428,24 @@ def _load_wifi_partial():
|
||||
try:
|
||||
return render_template('v3/partials/wifi.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_cache_partial():
|
||||
"""Load cache management partial"""
|
||||
try:
|
||||
return render_template('v3/partials/cache.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_operation_history_partial():
|
||||
"""Load operation history partial"""
|
||||
try:
|
||||
return render_template('v3/partials/operation_history.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
def _load_plugin_config_partial(plugin_id):
|
||||
@@ -336,6 +453,11 @@ def _load_plugin_config_partial(plugin_id):
|
||||
Load plugin configuration partial - server-side rendered form.
|
||||
This replaces the client-side generateConfigForm() JavaScript.
|
||||
"""
|
||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
|
||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
||||
|
||||
try:
|
||||
if not pages_v3.plugin_manager:
|
||||
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
|
||||
@@ -344,80 +466,85 @@ def _load_plugin_config_partial(plugin_id):
|
||||
if plugin_id.startswith('starlark:'):
|
||||
return _load_starlark_config_partial(plugin_id[len('starlark:'):])
|
||||
|
||||
# Resolve and validate all plugin paths against the plugins base directory
|
||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
||||
try:
|
||||
_plugin_dir.relative_to(_plugins_base)
|
||||
except ValueError:
|
||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
||||
|
||||
# Try to get plugin info first
|
||||
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
||||
|
||||
|
||||
# If not found, re-discover plugins (handles plugins added after startup)
|
||||
if not plugin_info:
|
||||
pages_v3.plugin_manager.discover_plugins()
|
||||
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
||||
|
||||
|
||||
if not plugin_info:
|
||||
return f'<div class="text-red-500 p-4">Plugin "{escape(plugin_id)}" not found</div>', 404
|
||||
|
||||
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
|
||||
|
||||
# Get plugin instance (may be None if not loaded)
|
||||
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
||||
|
||||
|
||||
# Get plugin configuration from config file
|
||||
config = {}
|
||||
if pages_v3.config_manager:
|
||||
full_config = pages_v3.config_manager.load_config()
|
||||
config = full_config.get(plugin_id, {})
|
||||
|
||||
|
||||
# Load uploaded images from metadata file if images field exists in schema
|
||||
# This ensures uploaded images appear even if config hasn't been saved yet
|
||||
schema_path_temp = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
|
||||
schema_path_temp = _plugin_dir / "config_schema.json"
|
||||
if schema_path_temp.exists():
|
||||
try:
|
||||
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
||||
temp_schema = json.load(f)
|
||||
# Check if schema has an images field with x-widget: file-upload
|
||||
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
||||
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
||||
# Load metadata file
|
||||
# Get PROJECT_ROOT relative to this file
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json'
|
||||
if metadata_file.exists():
|
||||
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
|
||||
metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve()
|
||||
try:
|
||||
metadata_file.relative_to(_assets_base)
|
||||
except ValueError:
|
||||
metadata_file = None
|
||||
if metadata_file and metadata_file.exists():
|
||||
try:
|
||||
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
||||
metadata = json.load(mf)
|
||||
# Convert metadata dict to list of image objects
|
||||
images_from_metadata = list(metadata.values())
|
||||
# Only use metadata images if config doesn't have images or config images is empty
|
||||
if not config.get('images') or len(config.get('images', [])) == 0:
|
||||
config['images'] = images_from_metadata
|
||||
else:
|
||||
# Merge: add metadata images that aren't already in config
|
||||
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
|
||||
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
|
||||
if new_images:
|
||||
config['images'] = config.get('images', []) + new_images
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load metadata for {plugin_id}: {e}")
|
||||
logger.warning("Could not load plugin upload metadata: %s", e)
|
||||
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
|
||||
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
|
||||
|
||||
|
||||
# Get plugin schema
|
||||
schema = {}
|
||||
schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
|
||||
schema_path = _plugin_dir / "config_schema.json"
|
||||
if schema_path.exists():
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load schema for {plugin_id}: {e}")
|
||||
|
||||
logger.warning("Could not load schema for plugin: %s", e)
|
||||
|
||||
# Get web UI actions from plugin manifest
|
||||
web_ui_actions = []
|
||||
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
||||
manifest_path = _plugin_dir / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
web_ui_actions = manifest.get('web_ui_actions', [])
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load manifest for {plugin_id}: {e}")
|
||||
logger.warning("Could not load manifest for plugin: %s", e)
|
||||
|
||||
# Mask secret fields before rendering template (fail closed — never leak secrets)
|
||||
schema_properties = schema.get('properties') if isinstance(schema, dict) else None
|
||||
@@ -453,20 +580,24 @@ def _load_plugin_config_partial(plugin_id):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
|
||||
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
||||
|
||||
|
||||
def _load_starlark_config_partial(app_id):
|
||||
"""Load configuration partial for a Starlark app."""
|
||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
||||
app_id = os.path.basename(app_id or '')
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
|
||||
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
|
||||
|
||||
try:
|
||||
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
|
||||
|
||||
if starlark_plugin and hasattr(starlark_plugin, 'apps'):
|
||||
app = starlark_plugin.apps.get(app_id)
|
||||
if not app:
|
||||
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
||||
return render_template(
|
||||
'v3/partials/starlark_config.html',
|
||||
app_id=app_id,
|
||||
@@ -482,36 +613,45 @@ def _load_starlark_config_partial(app_id):
|
||||
)
|
||||
|
||||
# Standalone: read from manifest file
|
||||
manifest_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / 'manifest.json'
|
||||
starlark_base = (Path(__file__).resolve().parent.parent.parent / 'starlark-apps').resolve()
|
||||
manifest_file = starlark_base / 'manifest.json'
|
||||
if not manifest_file.exists():
|
||||
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
||||
|
||||
with open(manifest_file, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
app_data = manifest.get('apps', {}).get(app_id)
|
||||
if not app_data:
|
||||
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
||||
|
||||
# Load schema from schema.json if it exists
|
||||
# Load schema from schema.json if it exists — validate path stays within starlark_base
|
||||
schema = None
|
||||
schema_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'schema.json'
|
||||
if schema_file.exists():
|
||||
schema_file = (starlark_base / app_id / 'schema.json').resolve()
|
||||
try:
|
||||
schema_file.relative_to(starlark_base)
|
||||
except ValueError:
|
||||
schema_file = None
|
||||
if schema_file and schema_file.exists():
|
||||
try:
|
||||
with open(schema_file, 'r') as f:
|
||||
schema = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"[Pages V3] Could not load schema for {app_id}: {e}", exc_info=True)
|
||||
logger.warning("Could not load starlark schema for app: %s", e)
|
||||
|
||||
# Load config from config.json if it exists
|
||||
# Load config from config.json if it exists — validate path stays within starlark_base
|
||||
config = {}
|
||||
config_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'config.json'
|
||||
if config_file.exists():
|
||||
config_file = (starlark_base / app_id / 'config.json').resolve()
|
||||
try:
|
||||
config_file.relative_to(starlark_base)
|
||||
except ValueError:
|
||||
config_file = None
|
||||
if config_file and config_file.exists():
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"[Pages V3] Could not load config for {app_id}: {e}", exc_info=True)
|
||||
logger.warning("Could not load starlark config for app: %s", e)
|
||||
|
||||
return render_template(
|
||||
'v3/partials/starlark_config.html',
|
||||
@@ -528,5 +668,5 @@ def _load_starlark_config_partial(app_id):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[Pages V3] Error loading starlark config for {app_id}")
|
||||
return f'<div class="text-red-500 p-4">Error loading starlark config: {str(e)}</div>', 500
|
||||
logger.error("[Pages V3] Error loading starlark config for app", exc_info=True)
|
||||
return '<div class="text-red-500 p-4">Error loading starlark config; see logs for details</div>', 500
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* global showNotification, updateSystemStats, htmx */
|
||||
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
|
||||
// LED Matrix v3 JavaScript
|
||||
// Additional helpers for HTMX and Alpine.js integration
|
||||
|
||||
@@ -51,7 +51,8 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
}
|
||||
});
|
||||
|
||||
// SSE reconnection helper
|
||||
// SSE reconnection helper — closes and reopens both SSE streams,
|
||||
// reattaching the open/error handlers defined in base.html.
|
||||
window.reconnectSSE = function() {
|
||||
if (window.statsSource) {
|
||||
window.statsSource.close();
|
||||
@@ -60,14 +61,18 @@ window.reconnectSSE = function() {
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof updateSystemStats === 'function') updateSystemStats(data);
|
||||
};
|
||||
if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler);
|
||||
if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler);
|
||||
}
|
||||
|
||||
if (window.displaySource) {
|
||||
window.displaySource.close();
|
||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||
window.displaySource.onmessage = function() {
|
||||
// Handle display updates
|
||||
window.displaySource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
|
||||
};
|
||||
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,8 +51,10 @@
|
||||
sanitizeValue(value) {
|
||||
// Base implementation - widgets should override for specific needs
|
||||
if (typeof value === 'string') {
|
||||
// Basic XSS prevention
|
||||
return value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
// Strip all HTML tags via the DOM parser to prevent XSS
|
||||
const div = document.createElement('div');
|
||||
div.textContent = value;
|
||||
return div.textContent;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
291
web_interface/static/v3/js/widgets/file-upload-single.js
Normal file
291
web_interface/static/v3/js/widgets/file-upload-single.js
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* LEDMatrix File Upload Single Widget
|
||||
*
|
||||
* Single-image upload for string fields. Uploads to the plugin's asset folder
|
||||
* and sets the string field value to the returned relative path.
|
||||
* Designed for per-item image fields within array-table rows.
|
||||
*
|
||||
* The plugin_id is injected automatically from the template context
|
||||
* via options.pluginId — no need to specify it in the schema.
|
||||
*
|
||||
* Schema example (any plugin):
|
||||
* {
|
||||
* "image_path": {
|
||||
* "type": "string",
|
||||
* "x-widget": "file-upload-single",
|
||||
* "x-upload-config": {
|
||||
* "allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
|
||||
* "max_size_mb": 5
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @module FileUploadSingleWidget
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[FileUploadSingleWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null;
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (base) return base.escapeHtml(text);
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeId(id) {
|
||||
if (base) return base.sanitizeId(id);
|
||||
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function triggerChange(fieldId, value) {
|
||||
if (base) {
|
||||
base.triggerChange(fieldId, value);
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('widget-change', {
|
||||
detail: { fieldId, value },
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function isImagePath(path) {
|
||||
if (!path) return false;
|
||||
return /\.(png|jpg|jpeg|bmp|gif)$/i.test(path);
|
||||
}
|
||||
|
||||
function safeSetHTML(target, html) {
|
||||
target.textContent = '';
|
||||
// createContextualFragment parses html relative to the document context
|
||||
// without executing scripts — a widely recognised safe insertion method.
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
window.LEDMatrixWidgets.register('file-upload-single', {
|
||||
name: 'File Upload Single Widget',
|
||||
version: '1.0.0',
|
||||
|
||||
render: function(container, config, value, options) {
|
||||
const fieldId = sanitizeId(options.fieldId || container.id || 'file_upload_single');
|
||||
const uploadConfig = config['x-upload-config'] || config['x_upload_config'] || {};
|
||||
const allowedTypes = (uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']).join(',');
|
||||
const maxSizeMb = uploadConfig.max_size_mb || 5;
|
||||
const pluginId = options.pluginId || '';
|
||||
const currentValue = value || '';
|
||||
const hasImage = isImagePath(currentValue);
|
||||
|
||||
let html = `<div id="${fieldId}_widget" class="file-upload-single-widget" data-field-id="${fieldId}" data-plugin-id="${escapeHtml(pluginId)}">`;
|
||||
|
||||
// Hidden input carries the actual string value
|
||||
html += `<input type="hidden" id="${fieldId}" name="${escapeHtml(options.name || fieldId)}" value="${escapeHtml(currentValue)}">`;
|
||||
|
||||
// Preview area (shown when a value is set)
|
||||
html += `<div id="${fieldId}_preview" class="${hasImage ? '' : 'hidden'} flex items-center space-x-3 mb-2 p-2 bg-gray-50 rounded border border-gray-200">`;
|
||||
html += `<img id="${fieldId}_thumb" src="/${escapeHtml(currentValue)}" alt="Preview"
|
||||
class="w-12 h-12 object-cover rounded"
|
||||
onerror="this.style.display='none';document.getElementById('${fieldId}_thumb_placeholder').style.display='flex'">`;
|
||||
html += `<div id="${fieldId}_thumb_placeholder" style="display:none" class="w-12 h-12 bg-gray-200 rounded flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400 text-lg"></i>
|
||||
</div>`;
|
||||
html += `<div class="flex-1 min-w-0">
|
||||
<p id="${fieldId}_filename" class="text-xs text-gray-600 truncate">${escapeHtml(currentValue.split('/').pop() || '')}</p>
|
||||
<p id="${fieldId}_fullpath" class="text-xs text-gray-400">${escapeHtml(currentValue)}</p>
|
||||
</div>`;
|
||||
html += `<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
|
||||
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>`;
|
||||
html += '</div>';
|
||||
|
||||
// Upload drop zone — keyboard accessible via tabindex + Enter/Space
|
||||
html += `<div id="${fieldId}_drop_zone"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
||||
role="button" tabindex="0"
|
||||
aria-label="${hasImage ? 'Replace image' : 'Upload image'}"
|
||||
ondrop="window.LEDMatrixWidgets.getHandlers('file-upload-single').onDrop(event, '${fieldId}')"
|
||||
ondragover="event.preventDefault()"
|
||||
onclick="document.getElementById('${fieldId}_file_input').click()"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('${fieldId}_file_input').click();}">
|
||||
<input type="file"
|
||||
id="${fieldId}_file_input"
|
||||
accept="${escapeHtml(allowedTypes)}"
|
||||
style="display:none"
|
||||
data-field-id="${fieldId}"
|
||||
data-plugin-id="${escapeHtml(pluginId)}"
|
||||
data-max-size-mb="${maxSizeMb}"
|
||||
data-allowed-types="${escapeHtml(allowedTypes)}"
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('file-upload-single').onFileSelect(event, '${fieldId}')">
|
||||
<i class="fas fa-cloud-upload-alt text-xl text-gray-400 mb-1"></i>
|
||||
<p class="text-xs text-gray-500">${hasImage ? 'Click to replace image' : 'Click or drag to upload image'}</p>
|
||||
<p class="text-xs text-gray-400">Max ${maxSizeMb}MB</p>
|
||||
</div>`;
|
||||
|
||||
// Status area for upload feedback
|
||||
html += `<div id="${fieldId}_status" class="mt-1 text-xs hidden"></div>`;
|
||||
|
||||
html += '</div>';
|
||||
safeSetHTML(container, html);
|
||||
},
|
||||
|
||||
getValue: function(fieldId) {
|
||||
const safeId = sanitizeId(fieldId);
|
||||
const input = document.getElementById(safeId);
|
||||
return input ? input.value : '';
|
||||
},
|
||||
|
||||
setValue: function(fieldId, value) {
|
||||
const safeId = sanitizeId(fieldId);
|
||||
const hidden = document.getElementById(safeId);
|
||||
const preview = document.getElementById(`${safeId}_preview`);
|
||||
const thumb = document.getElementById(`${safeId}_thumb`);
|
||||
const thumbPlaceholder = document.getElementById(`${safeId}_thumb_placeholder`);
|
||||
const filename = document.getElementById(`${safeId}_filename`);
|
||||
const dropZone = document.getElementById(`${safeId}_drop_zone`);
|
||||
|
||||
if (hidden) hidden.value = value || '';
|
||||
|
||||
const hasImage = isImagePath(value);
|
||||
if (preview) preview.classList.toggle('hidden', !hasImage);
|
||||
if (thumb && hasImage) {
|
||||
thumb.src = `/${value}`;
|
||||
thumb.style.display = '';
|
||||
if (thumbPlaceholder) thumbPlaceholder.style.display = 'none';
|
||||
}
|
||||
if (filename) filename.textContent = hasImage ? value.split('/').pop() : '';
|
||||
const fullpath = document.getElementById(`${safeId}_fullpath`);
|
||||
if (fullpath) fullpath.textContent = value || '';
|
||||
|
||||
// Update drop zone hint text
|
||||
const hint = dropZone ? dropZone.querySelector('p') : null;
|
||||
if (hint) hint.textContent = hasImage ? 'Click to replace image' : 'Click or drag to upload image';
|
||||
},
|
||||
|
||||
handlers: {
|
||||
onFileSelect: function(event, fieldId) {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
window.LEDMatrixWidgets.getHandlers('file-upload-single').uploadFile(fieldId, files[0]);
|
||||
}
|
||||
},
|
||||
|
||||
onDrop: function(event, fieldId) {
|
||||
event.preventDefault();
|
||||
const files = event.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
window.LEDMatrixWidgets.getHandlers('file-upload-single').uploadFile(fieldId, files[0]);
|
||||
}
|
||||
},
|
||||
|
||||
onClear: function(fieldId) {
|
||||
const widget = window.LEDMatrixWidgets.get('file-upload-single');
|
||||
widget.setValue(fieldId, '');
|
||||
triggerChange(fieldId, '');
|
||||
// Reset file input so the same file can be re-selected
|
||||
const fileInput = document.getElementById(`${sanitizeId(fieldId)}_file_input`);
|
||||
if (fileInput) fileInput.value = '';
|
||||
},
|
||||
|
||||
uploadFile: async function(fieldId, file) {
|
||||
const safeId = sanitizeId(fieldId);
|
||||
const fileInput = document.getElementById(`${safeId}_file_input`);
|
||||
const statusDiv = document.getElementById(`${safeId}_status`);
|
||||
const notifyFn = window.showNotification || console.log;
|
||||
|
||||
// Read config from the file input data attributes
|
||||
const pluginId = (fileInput && fileInput.dataset.pluginId) || '';
|
||||
const maxSizeMb = parseFloat((fileInput && fileInput.dataset.maxSizeMb) || '5');
|
||||
const allowedTypes = ((fileInput && fileInput.dataset.allowedTypes) || 'image/png,image/jpeg,image/bmp,image/gif')
|
||||
.split(',').map(t => t.trim());
|
||||
|
||||
if (!pluginId) {
|
||||
notifyFn('Plugin ID not set — cannot upload', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
notifyFn(`File type "${file.type}" not allowed`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > maxSizeMb * 1024 * 1024) {
|
||||
notifyFn(`File exceeds ${maxSizeMb}MB limit`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show uploading status — use DOM methods to avoid innerHTML with dynamic data
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-1 text-xs text-gray-500';
|
||||
statusDiv.textContent = '';
|
||||
const spinner = document.createElement('i');
|
||||
spinner.className = 'fas fa-spinner fa-spin mr-1';
|
||||
statusDiv.appendChild(spinner);
|
||||
statusDiv.appendChild(document.createTextNode('Uploading…'));
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('plugin_id', pluginId);
|
||||
formData.append('files', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v3/plugins/assets/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Server error ${response.status}: ${body}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
|
||||
const uploadedPath = data.uploaded_files[0].path;
|
||||
const widget = window.LEDMatrixWidgets.get('file-upload-single');
|
||||
widget.setValue(fieldId, uploadedPath);
|
||||
triggerChange(fieldId, uploadedPath);
|
||||
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-1 text-xs text-green-600';
|
||||
statusDiv.textContent = '';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-check-circle mr-1';
|
||||
statusDiv.appendChild(icon);
|
||||
statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
|
||||
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000);
|
||||
}
|
||||
notifyFn('Image uploaded successfully', 'success');
|
||||
} else {
|
||||
throw new Error(data.message || 'Upload failed');
|
||||
}
|
||||
} catch (error) {
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-1 text-xs text-red-600';
|
||||
statusDiv.textContent = '';
|
||||
const errIcon = document.createElement('i');
|
||||
errIcon.className = 'fas fa-exclamation-circle mr-1';
|
||||
statusDiv.appendChild(errIcon);
|
||||
statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
|
||||
}
|
||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[FileUploadSingleWidget] File upload single widget registered');
|
||||
})();
|
||||
783
web_interface/static/v3/js/widgets/json-file-manager.js
Normal file
783
web_interface/static/v3/js/widgets/json-file-manager.js
Normal file
@@ -0,0 +1,783 @@
|
||||
/**
|
||||
* JsonFileManager — reusable JSON file management widget for LEDMatrix plugins.
|
||||
*
|
||||
* Usage via config_schema.json:
|
||||
* "file_manager": {
|
||||
* "type": "null",
|
||||
* "title": "Data Files",
|
||||
* "x-widget": "json-file-manager",
|
||||
* "x-widget-config": {
|
||||
* "actions": {
|
||||
* "list": "list-files", // required
|
||||
* "get": "get-file", // required for editing
|
||||
* "save": "save-file", // required for editing
|
||||
* "upload": "upload-file", // optional
|
||||
* "delete": "delete-file", // optional
|
||||
* "create": "create-file", // optional
|
||||
* "toggle": "toggle-category" // optional
|
||||
* },
|
||||
* "upload_hint": "Hint text under the drop zone",
|
||||
* "directory_label": "of_the_day/",
|
||||
* "create_fields": [
|
||||
* { "key": "category_name", "label": "Category Name",
|
||||
* "placeholder": "my_words", "pattern": "^[a-z0-9_]+$",
|
||||
* "hint": "Used as filename" },
|
||||
* { "key": "display_name", "label": "Display Name",
|
||||
* "placeholder": "My Words" }
|
||||
* ],
|
||||
* "toggle_key": "category_name"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* No CDN dependencies. Works on all modern browsers.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
class JsonFileManager {
|
||||
constructor(container, config, pluginId) {
|
||||
// Prevent duplicate instances on the same container
|
||||
if (container._jfmInstance) {
|
||||
container._jfmInstance._destroy();
|
||||
}
|
||||
container._jfmInstance = this;
|
||||
|
||||
this.el = container;
|
||||
this.pluginId = pluginId;
|
||||
this.actions = config.actions || {};
|
||||
this.uploadHint = config.upload_hint || '';
|
||||
this.dirLabel = config.directory_label || '';
|
||||
this.createFields = config.create_fields || [];
|
||||
this.toggleKey = config.toggle_key || null;
|
||||
|
||||
// Unique prefix for all DOM IDs in this instance
|
||||
this._uid = 'jfm_' + Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Mutable state
|
||||
this._editFile = null;
|
||||
this._deleteFile = null;
|
||||
this._keyHandler = this._onKey.bind(this);
|
||||
|
||||
this._inject();
|
||||
this._bind();
|
||||
this._loadList();
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
_destroy() {
|
||||
document.removeEventListener('keydown', this._keyHandler);
|
||||
this.el._jfmInstance = null;
|
||||
}
|
||||
|
||||
// ── DOM Injection ────────────────────────────────────────────────────
|
||||
|
||||
_inject() {
|
||||
const u = this._uid;
|
||||
const hasUpload = !!this.actions.upload;
|
||||
const hasCreate = !!this.actions.create;
|
||||
const hasDelete = !!this.actions.delete;
|
||||
|
||||
this.el.innerHTML = this._css(u) + `
|
||||
<div id="${u}" class="jfm">
|
||||
|
||||
<div class="jfm-header">
|
||||
<div class="jfm-header-left">
|
||||
<span class="jfm-title">Data Files</span>
|
||||
${this.dirLabel ? `<code class="jfm-dir">${this._esc(this.dirLabel)}</code>` : ''}
|
||||
</div>
|
||||
<div class="jfm-header-right">
|
||||
${hasCreate ? `<button type="button" class="jfm-btn jfm-btn-primary jfm-btn-sm" data-jfm="open-create">+ New File</button>` : ''}
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="refresh" title="Refresh file list">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="${u}-list" class="jfm-list">
|
||||
<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>
|
||||
</div>
|
||||
|
||||
${hasUpload ? `
|
||||
<div class="jfm-upload-wrap">
|
||||
<input type="file" accept=".json" id="${u}-fileinput" tabindex="-1">
|
||||
<div class="jfm-dropzone" id="${u}-dropzone" data-jfm="open-picker" role="button" tabindex="0"
|
||||
aria-label="Upload JSON file">
|
||||
<span class="jfm-drop-icon">📁</span>
|
||||
<p class="jfm-drop-primary">Drop a JSON file here, or click to browse</p>
|
||||
${this.uploadHint ? `<p class="jfm-drop-hint">${this._esc(this.uploadHint)}</p>` : ''}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- ── Edit modal ─────────────────────────────────────── -->
|
||||
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal-box jfm-modal-wide">
|
||||
<div class="jfm-modal-head">
|
||||
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
|
||||
<div class="jfm-modal-tools">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
|
||||
<textarea id="${u}-editor" class="jfm-editor"
|
||||
spellcheck="false" autocomplete="off"
|
||||
autocorrect="off" autocapitalize="off"
|
||||
aria-label="JSON editor"></textarea>
|
||||
<div class="jfm-modal-foot">
|
||||
<span id="${u}-charcount" class="jfm-stat"></span>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="save" id="${u}-save-btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Delete modal ───────────────────────────────────── -->
|
||||
${hasDelete ? `
|
||||
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal-box">
|
||||
<div class="jfm-modal-head">
|
||||
<span class="jfm-modal-title">Delete file</span>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="jfm-modal-body">
|
||||
<p>Delete <strong id="${u}-del-name"></strong>?</p>
|
||||
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
|
||||
</div>
|
||||
<div class="jfm-modal-foot">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- ── Create modal ───────────────────────────────────── -->
|
||||
${hasCreate ? `
|
||||
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal-box">
|
||||
<div class="jfm-modal-head">
|
||||
<span class="jfm-modal-title">Create new file</span>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="jfm-modal-body">
|
||||
${this.createFields.map(f => `
|
||||
<div class="jfm-field">
|
||||
<label for="${u}-cf-${this._esc(f.key)}">${this._esc(f.label)}</label>
|
||||
<input type="text" id="${u}-cf-${this._esc(f.key)}"
|
||||
placeholder="${this._esc(f.placeholder || '')}"
|
||||
${f.pattern ? `pattern="${this._esc(f.pattern)}"` : ''}>
|
||||
${f.hint ? `<span class="jfm-hint">${this._esc(f.hint)}</span>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="jfm-modal-foot">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-create">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="do-create" id="${u}-create-btn">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
</div>`; // end #${u}
|
||||
|
||||
// Cache frequently-used elements
|
||||
this._root = document.getElementById(u);
|
||||
this._listEl = document.getElementById(`${u}-list`);
|
||||
this._editorEl = document.getElementById(`${u}-editor`);
|
||||
this._editModal = document.getElementById(`${u}-edit-modal`);
|
||||
this._delModal = document.getElementById(`${u}-del-modal`);
|
||||
this._createModal = document.getElementById(`${u}-create-modal`);
|
||||
this._dropzone = document.getElementById(`${u}-dropzone`);
|
||||
this._fileInput = document.getElementById(`${u}-fileinput`);
|
||||
}
|
||||
|
||||
_css(u) {
|
||||
return `<style>
|
||||
#${u}{font-family:inherit;color:#111827;}
|
||||
#${u} *{box-sizing:border-box;}
|
||||
|
||||
/* Header */
|
||||
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
|
||||
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
|
||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
|
||||
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
||||
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
|
||||
|
||||
/* Buttons */
|
||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
||||
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
|
||||
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
|
||||
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
|
||||
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
|
||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
|
||||
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
|
||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
|
||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
|
||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
|
||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
||||
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
|
||||
|
||||
/* File list */
|
||||
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
|
||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
|
||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
|
||||
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
|
||||
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
|
||||
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
|
||||
|
||||
/* File cards */
|
||||
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
||||
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
|
||||
#${u} .jfm-card.jfm-off{opacity:.6;}
|
||||
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
|
||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
|
||||
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
|
||||
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
|
||||
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
|
||||
|
||||
/* Toggle */
|
||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
|
||||
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
|
||||
|
||||
/* Upload zone */
|
||||
#${u} .jfm-upload-wrap{margin-top:.25rem;}
|
||||
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
|
||||
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
|
||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
|
||||
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
|
||||
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
|
||||
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
|
||||
|
||||
/* Modals */
|
||||
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
|
||||
#${u} .jfm-modal[hidden]{display:none;}
|
||||
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
||||
#${u} .jfm-modal-wide{max-width:880px;}
|
||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
|
||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
|
||||
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
|
||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
|
||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
|
||||
|
||||
/* JSON editor */
|
||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
|
||||
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
||||
#${u} .jfm-err-bar[hidden]{display:none;}
|
||||
|
||||
/* Create form */
|
||||
#${u} .jfm-field{margin-bottom:.875rem;}
|
||||
#${u} .jfm-field:last-child{margin-bottom:0;}
|
||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
|
||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
|
||||
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
|
||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
|
||||
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
|
||||
|
||||
/* Spinner */
|
||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
||||
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
|
||||
</style>`;
|
||||
}
|
||||
|
||||
// ── Event Binding ────────────────────────────────────────────────────
|
||||
|
||||
_bind() {
|
||||
// Delegated clicks on the widget root
|
||||
this._root.addEventListener('click', this._onClick.bind(this));
|
||||
this._root.addEventListener('change', this._onChange.bind(this));
|
||||
|
||||
// Drag-and-drop on the dropzone
|
||||
if (this._dropzone) {
|
||||
this._dropzone.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
this._dropzone.classList.add('jfm-over');
|
||||
});
|
||||
this._dropzone.addEventListener('dragleave', () => {
|
||||
this._dropzone.classList.remove('jfm-over');
|
||||
});
|
||||
this._dropzone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
this._dropzone.classList.remove('jfm-over');
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) this._uploadFile(file);
|
||||
});
|
||||
// Keyboard activation of drop zone
|
||||
this._dropzone.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this._fileInput?.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Modal backdrop clicks
|
||||
[this._editModal, this._delModal, this._createModal].forEach(m => {
|
||||
if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); });
|
||||
});
|
||||
|
||||
// Editor: char count + Tab indent
|
||||
if (this._editorEl) {
|
||||
this._editorEl.addEventListener('input', () => this._updateStat());
|
||||
this._editorEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const s = this._editorEl.selectionStart;
|
||||
const end = this._editorEl.selectionEnd;
|
||||
const v = this._editorEl.value;
|
||||
this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end);
|
||||
this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2;
|
||||
this._updateStat();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Global keyboard shortcuts
|
||||
document.addEventListener('keydown', this._keyHandler);
|
||||
}
|
||||
|
||||
_onKey(e) {
|
||||
const editOpen = this._editModal && !this._editModal.hidden;
|
||||
const delOpen = this._delModal && !this._delModal.hidden;
|
||||
const createOpen = this._createModal && !this._createModal.hidden;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (editOpen) { this._closeEdit(); return; }
|
||||
if (delOpen) { this._closeDel(); return; }
|
||||
if (createOpen) { this._closeCreate(); return; }
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
|
||||
e.preventDefault();
|
||||
this._doSave();
|
||||
}
|
||||
}
|
||||
|
||||
_onClick(e) {
|
||||
const btn = e.target.closest('[data-jfm]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.jfm;
|
||||
|
||||
switch (action) {
|
||||
case 'refresh': this._loadList(); break;
|
||||
case 'open-picker': this._fileInput?.click(); break;
|
||||
case 'open-create': this._openCreate(); break;
|
||||
case 'close-edit': this._closeEdit(); break;
|
||||
case 'close-del': this._closeDel(); break;
|
||||
case 'close-create': this._closeCreate(); break;
|
||||
case 'fmt': this._formatJson(); break;
|
||||
case 'validate': this._validateJson(); break;
|
||||
case 'save': this._doSave(); break;
|
||||
case 'confirm-del': this._doDelete(); break;
|
||||
case 'do-create': this._doCreate(); break;
|
||||
case 'edit-file': {
|
||||
const card = btn.closest('[data-jfm-file]');
|
||||
if (card) this._openEdit(card.dataset.jfmFile);
|
||||
break;
|
||||
}
|
||||
case 'del-file': {
|
||||
const card = btn.closest('[data-jfm-file]');
|
||||
if (card) this._openDel(card.dataset.jfmFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onChange(e) {
|
||||
// Toggle checkbox
|
||||
if (e.target.classList.contains('jfm-toggle-cb')) {
|
||||
const catName = e.target.dataset.cat;
|
||||
const enabled = e.target.checked;
|
||||
this._doToggle(catName, enabled, e.target);
|
||||
}
|
||||
// File input
|
||||
if (e.target === this._fileInput) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) this._uploadFile(file);
|
||||
e.target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── API helper ───────────────────────────────────────────────────────
|
||||
|
||||
async _api(actionKey, params) {
|
||||
const actionId = Object.prototype.hasOwnProperty.call(this.actions, actionKey) ? this.actions[actionKey] : undefined;
|
||||
if (!actionId) throw new Error(`Action "${actionKey}" not configured`);
|
||||
const body = { plugin_id: this.pluginId, action_id: actionId };
|
||||
if (params !== undefined) body.params = params;
|
||||
const r = await fetch('/api/v3/plugins/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!r.ok) throw new Error('Server error ' + r.status);
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
if (!ct.includes('application/json')) {
|
||||
const txt = await r.text();
|
||||
throw new Error('Unexpected response: ' + txt.slice(0, 120));
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ── File List ────────────────────────────────────────────────────────
|
||||
|
||||
async _loadList() {
|
||||
this._listEl.innerHTML = `<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>`;
|
||||
try {
|
||||
const data = await this._api('list');
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
|
||||
this._renderList(data.files || []);
|
||||
} catch (err) {
|
||||
this._listEl.innerHTML = `
|
||||
<div class="jfm-empty">
|
||||
<div class="jfm-empty-icon">⚠</div>
|
||||
<p class="jfm-empty-title">Failed to load files</p>
|
||||
<p class="jfm-empty-sub">${this._esc(err.message)}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
_renderList(files) {
|
||||
if (!files.length) {
|
||||
this._listEl.innerHTML = `
|
||||
<div class="jfm-empty">
|
||||
<div class="jfm-empty-icon">📁</div>
|
||||
<p class="jfm-empty-title">No files yet</p>
|
||||
<p class="jfm-empty-sub">Upload or create a JSON file to get started</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
this._listEl.innerHTML = files.map(f => this._card(f)).join('');
|
||||
}
|
||||
|
||||
_card(f) {
|
||||
const enabled = f.enabled !== false;
|
||||
const displayName = this._esc(f.display_name || f.filename);
|
||||
const filename = this._esc(f.filename);
|
||||
const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : '';
|
||||
const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]);
|
||||
const hasEdit = !!this.actions.get && !!this.actions.save;
|
||||
const hasDelete = !!this.actions.delete;
|
||||
|
||||
return `
|
||||
<div class="jfm-card${enabled ? '' : ' jfm-off'}" data-jfm-file="${filename}">
|
||||
<div class="jfm-card-top">
|
||||
<span class="jfm-card-name" title="${filename}">${displayName}</span>
|
||||
${showToggle ? `
|
||||
<label class="jfm-toggle" title="${enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}">
|
||||
<input type="checkbox" class="jfm-toggle-cb" data-cat="${catName}" ${enabled ? 'checked' : ''}>
|
||||
<span>${enabled ? 'On' : 'Off'}</span>
|
||||
</label>` : ''}
|
||||
</div>
|
||||
<div class="jfm-card-meta">
|
||||
<span>📄 ${filename}</span>
|
||||
<span>📊 ${f.entry_count ?? 0} entries · ${this._fmtSize(f.size || 0)}</span>
|
||||
<span>🕑 ${this._fmtDate(f.modified)}</span>
|
||||
</div>
|
||||
<div class="jfm-card-actions">
|
||||
${hasEdit ? `<button type="button" class="jfm-btn jfm-btn-sm" data-jfm="edit-file">✎ Edit</button>` : ''}
|
||||
${hasDelete ? `<button type="button" class="jfm-btn jfm-btn-danger jfm-btn-sm jfm-del" data-jfm="del-file" title="Delete file">🗑</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Edit flow ────────────────────────────────────────────────────────
|
||||
|
||||
async _openEdit(filename) {
|
||||
this._editFile = filename;
|
||||
document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`;
|
||||
this._clearErr();
|
||||
this._editorEl.value = 'Loading…';
|
||||
this._updateStat();
|
||||
this._editModal.hidden = false;
|
||||
|
||||
try {
|
||||
const data = await this._api('get', { filename });
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
|
||||
this._editorEl.value = JSON.stringify(data.content, null, 2);
|
||||
this._updateStat();
|
||||
this._editorEl.focus();
|
||||
this._editorEl.setSelectionRange(0, 0);
|
||||
this._editorEl.scrollTop = 0;
|
||||
} catch (err) {
|
||||
this._showErr('Failed to load file: ' + err.message);
|
||||
this._editorEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
_closeEdit() {
|
||||
if (this._editModal) this._editModal.hidden = true;
|
||||
this._editFile = null;
|
||||
this._clearErr();
|
||||
}
|
||||
|
||||
_formatJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(this._editorEl.value);
|
||||
this._editorEl.value = JSON.stringify(parsed, null, 2);
|
||||
this._updateStat();
|
||||
this._clearErr();
|
||||
} catch (err) {
|
||||
this._showErr('Invalid JSON — ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
_validateJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(this._editorEl.value);
|
||||
const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?';
|
||||
this._clearErr();
|
||||
this._notify(`Valid JSON — ${n} top-level keys`, 'success');
|
||||
} catch (err) {
|
||||
this._showErr('Invalid JSON — ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async _doSave() {
|
||||
if (!this._editFile) return;
|
||||
let contentStr;
|
||||
try {
|
||||
const parsed = JSON.parse(this._editorEl.value);
|
||||
contentStr = JSON.stringify(parsed, null, 2);
|
||||
} catch (err) {
|
||||
this._showErr('Cannot save — fix JSON first: ' + err.message);
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById(`${this._uid}-save-btn`);
|
||||
this._busy(btn, 'Saving…');
|
||||
try {
|
||||
const data = await this._api('save', { filename: this._editFile, content: contentStr });
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Save failed');
|
||||
this._notify('File saved', 'success');
|
||||
this._closeEdit();
|
||||
this._loadList();
|
||||
} catch (err) {
|
||||
this._showErr('Save failed: ' + err.message);
|
||||
} finally {
|
||||
this._idle(btn, 'Save');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete flow ──────────────────────────────────────────────────────
|
||||
|
||||
_openDel(filename) {
|
||||
this._deleteFile = filename;
|
||||
const el = document.getElementById(`${this._uid}-del-name`);
|
||||
if (el) el.textContent = filename;
|
||||
if (this._delModal) this._delModal.hidden = false;
|
||||
}
|
||||
|
||||
_closeDel() {
|
||||
if (this._delModal) this._delModal.hidden = true;
|
||||
this._deleteFile = null;
|
||||
}
|
||||
|
||||
async _doDelete() {
|
||||
if (!this._deleteFile) return;
|
||||
const btn = document.getElementById(`${this._uid}-del-btn`);
|
||||
this._busy(btn, 'Deleting…');
|
||||
try {
|
||||
const data = await this._api('delete', { filename: this._deleteFile });
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Delete failed');
|
||||
this._notify('File deleted', 'success');
|
||||
this._closeDel();
|
||||
this._loadList();
|
||||
} catch (err) {
|
||||
this._notify('Delete failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
this._idle(btn, 'Delete');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create flow ──────────────────────────────────────────────────────
|
||||
|
||||
_openCreate() {
|
||||
if (!this._createModal) return;
|
||||
this.createFields.forEach(f => {
|
||||
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
this._createModal.hidden = false;
|
||||
const first = this.createFields[0];
|
||||
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
|
||||
}
|
||||
|
||||
_closeCreate() {
|
||||
if (this._createModal) this._createModal.hidden = true;
|
||||
}
|
||||
|
||||
async _doCreate() {
|
||||
const params = {};
|
||||
for (const f of this.createFields) {
|
||||
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
|
||||
const val = (el?.value || '').trim();
|
||||
// display_name may be blank — auto-derived from category_name below
|
||||
if (!val && f.key !== 'display_name') {
|
||||
this._notify(`"${f.label}" is required`, 'error');
|
||||
el?.focus();
|
||||
return;
|
||||
}
|
||||
if (f.pattern && val && el && el.validity.patternMismatch) {
|
||||
this._notify(`"${f.label}" format is invalid`, 'error');
|
||||
el?.focus();
|
||||
return;
|
||||
}
|
||||
if (val) params[f.key] = val;
|
||||
}
|
||||
// Auto-derive display_name from category_name when left blank
|
||||
if (!params.display_name && params.category_name) {
|
||||
params.display_name = params.category_name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
const btn = document.getElementById(`${this._uid}-create-btn`);
|
||||
this._busy(btn, 'Creating…');
|
||||
try {
|
||||
const data = await this._api('create', params);
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Create failed');
|
||||
this._notify('File created', 'success');
|
||||
this._closeCreate();
|
||||
this._loadList();
|
||||
} catch (err) {
|
||||
this._notify('Create failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
this._idle(btn, 'Create');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload ───────────────────────────────────────────────────────────
|
||||
|
||||
async _uploadFile(file) {
|
||||
if (!file.name.endsWith('.json')) {
|
||||
this._notify('Please select a .json file', 'error');
|
||||
return;
|
||||
}
|
||||
let content;
|
||||
try {
|
||||
content = await file.text();
|
||||
JSON.parse(content); // client-side validation
|
||||
} catch (err) {
|
||||
this._notify('Invalid JSON: ' + err.message, 'error');
|
||||
return;
|
||||
}
|
||||
if (this._dropzone) this._dropzone.style.opacity = '.5';
|
||||
try {
|
||||
const data = await this._api('upload', { filename: file.name, content });
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
|
||||
this._notify(`"${file.name}" uploaded`, 'success');
|
||||
this._loadList();
|
||||
} catch (err) {
|
||||
this._notify('Upload failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
if (this._dropzone) this._dropzone.style.opacity = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle ───────────────────────────────────────────────────────────
|
||||
|
||||
async _doToggle(catName, enabled, checkbox) {
|
||||
checkbox.disabled = true;
|
||||
try {
|
||||
const params = { enabled };
|
||||
if (this.toggleKey) params[this.toggleKey] = catName;
|
||||
const data = await this._api('toggle', params);
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Toggle failed');
|
||||
this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success');
|
||||
this._loadList();
|
||||
} catch (err) {
|
||||
this._notify('Toggle failed: ' + err.message, 'error');
|
||||
checkbox.checked = !enabled; // revert
|
||||
checkbox.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
_closeAll() {
|
||||
this._closeEdit();
|
||||
this._closeDel();
|
||||
this._closeCreate();
|
||||
}
|
||||
|
||||
_updateStat() {
|
||||
const v = this._editorEl?.value || '';
|
||||
const lines = v ? v.split('\n').length : 0;
|
||||
const el = document.getElementById(`${this._uid}-charcount`);
|
||||
if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`;
|
||||
}
|
||||
|
||||
_showErr(msg) {
|
||||
const el = document.getElementById(`${this._uid}-edit-err`);
|
||||
if (el) { el.textContent = msg; el.hidden = false; }
|
||||
}
|
||||
|
||||
_clearErr() {
|
||||
const el = document.getElementById(`${this._uid}-edit-err`);
|
||||
if (el) { el.textContent = ''; el.hidden = true; }
|
||||
}
|
||||
|
||||
_notify(msg, type) {
|
||||
if (typeof window.showNotification === 'function') {
|
||||
window.showNotification(msg, type || 'info');
|
||||
} else {
|
||||
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
_busy(btn, label) {
|
||||
if (!btn) return;
|
||||
btn._jfmOrigText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '';
|
||||
const spin = document.createElement('span');
|
||||
spin.className = 'jfm-spin';
|
||||
btn.appendChild(spin);
|
||||
btn.appendChild(document.createTextNode(' ' + label));
|
||||
}
|
||||
|
||||
_idle(btn, label) {
|
||||
if (!btn) return;
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn._jfmOrigText !== undefined ? btn._jfmOrigText : label;
|
||||
delete btn._jfmOrigText;
|
||||
}
|
||||
|
||||
_esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(str ?? '');
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
_fmtSize(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2);
|
||||
const unit = ['B', 'KB', 'MB'][i];
|
||||
const val = bytes / Math.pow(1024, i);
|
||||
return (i ? val.toFixed(1) : val) + ' ' + unit;
|
||||
}
|
||||
|
||||
_fmtDate(str) {
|
||||
if (!str) return '—';
|
||||
try {
|
||||
return new Date(str).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', year: 'numeric'
|
||||
});
|
||||
} catch { return str; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Widget registry integration ──────────────────────────────────────────
|
||||
|
||||
window.JsonFileManager = JsonFileManager;
|
||||
|
||||
if (typeof window.LEDMatrixWidgets !== 'undefined') {
|
||||
window.LEDMatrixWidgets.register('json-file-manager', {
|
||||
name: 'JSON File Manager',
|
||||
version: '1.0.0',
|
||||
render(container, config, _value, options) {
|
||||
new JsonFileManager(container, config || {}, options?.pluginId || '');
|
||||
},
|
||||
getValue() { return null; },
|
||||
setValue() {}
|
||||
});
|
||||
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
|
||||
} else {
|
||||
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
|
||||
}
|
||||
})();
|
||||
797
web_interface/static/v3/js/widgets/plugin-file-manager.js
Normal file
797
web_interface/static/v3/js/widgets/plugin-file-manager.js
Normal file
@@ -0,0 +1,797 @@
|
||||
/**
|
||||
* Plugin File Manager Widget
|
||||
*
|
||||
* Reusable inline file manager for plugins that manage files via the
|
||||
* web_ui_actions system. Driven entirely by x-widget-config in the schema —
|
||||
* no external HTML file or iframe needed.
|
||||
*
|
||||
* Any plugin can adopt this widget by:
|
||||
* 1. Defining web_ui_actions in manifest.json (list, get, save, upload,
|
||||
* delete, create, toggle) with ui_hidden: true
|
||||
* 2. Adding x-widget: "plugin-file-manager" to a field in config_schema.json
|
||||
* with x-widget-config mapping the action IDs
|
||||
*
|
||||
* Schema example:
|
||||
* {
|
||||
* "file_manager": {
|
||||
* "type": "null",
|
||||
* "title": "Data Files",
|
||||
* "x-widget": "plugin-file-manager",
|
||||
* "x-widget-config": {
|
||||
* "actions": {
|
||||
* "list": "list-files",
|
||||
* "get": "get-file",
|
||||
* "save": "save-file",
|
||||
* "upload": "upload-file",
|
||||
* "delete": "delete-file",
|
||||
* "create": "create-file",
|
||||
* "toggle": "toggle-category"
|
||||
* },
|
||||
* "upload_hint": "JSON files with day numbers 1–365 as keys",
|
||||
* "directory_label": "of_the_day/",
|
||||
* "create_fields": [
|
||||
* { "key": "category_name", "label": "Category Name",
|
||||
* "placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
|
||||
* "hint": "Lowercase letters, numbers, underscores" },
|
||||
* { "key": "display_name", "label": "Display Name",
|
||||
* "placeholder": "e.g., My Words", "hint": "Optional — auto-generated if blank" }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @module PluginFileManagerWidget
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[PluginFileManager] LEDMatrixWidgets registry not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Inject widget-scoped styles once ────────────────────────────────────
|
||||
|
||||
if (!document.getElementById('pfm-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'pfm-styles';
|
||||
style.textContent = `
|
||||
.pfm-root { font-family: inherit; }
|
||||
.pfm-header { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-bottom:.75rem; }
|
||||
.pfm-title { font-size:1rem; font-weight:600; color:#111827; }
|
||||
.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; }
|
||||
.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem;
|
||||
text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
|
||||
.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; }
|
||||
.pfm-upload p { font-size:.875rem; color:#4b5563; margin:.25rem 0 0; }
|
||||
.pfm-upload small { font-size:.75rem; color:#9ca3af; }
|
||||
.pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
|
||||
gap:.75rem; margin-top:.75rem; }
|
||||
.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem;
|
||||
background:#fff; transition:box-shadow .15s; }
|
||||
.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); }
|
||||
.pfm-card.disabled { opacity:.55; }
|
||||
.pfm-card-top { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-bottom:.5rem; }
|
||||
.pfm-card-icon { width:2rem; height:2rem; background:#f3f4f6; border-radius:.375rem;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
color:#6b7280; font-size:1rem; }
|
||||
.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; }
|
||||
.pfm-card-meta { font-size:.75rem; color:#6b7280; line-height:1.5; }
|
||||
.pfm-card-actions { display:flex; gap:.375rem; margin-top:.625rem; }
|
||||
.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem;
|
||||
border-radius:.375rem; font-size:.8125rem; font-weight:500;
|
||||
border:none; cursor:pointer; transition:background .15s; }
|
||||
.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; }
|
||||
.pfm-btn-primary:hover { background:#1d4ed8; }
|
||||
.pfm-btn-danger { background:#dc2626; color:#fff; }
|
||||
.pfm-btn-danger:hover { background:#b91c1c; }
|
||||
.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; }
|
||||
.pfm-btn-secondary:hover { background:#e5e7eb; }
|
||||
.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; }
|
||||
.pfm-btn-create { background:#059669; color:#fff; }
|
||||
.pfm-btn-create:hover { background:#047857; }
|
||||
.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; }
|
||||
.pfm-toggle-label { font-size:.75rem; color:#6b7280; }
|
||||
.pfm-toggle-cb { position:relative; display:inline-block; width:2rem; height:1.125rem; }
|
||||
.pfm-toggle-cb input { opacity:0; width:0; height:0; }
|
||||
.pfm-toggle-slider { position:absolute; inset:0; background:#d1d5db; border-radius:9999px;
|
||||
cursor:pointer; transition:background .2s; }
|
||||
.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem;
|
||||
left:.1875rem; bottom:.1875rem; background:#fff;
|
||||
border-radius:50%; transition:transform .2s; }
|
||||
.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; }
|
||||
.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); }
|
||||
.pfm-empty { text-align:center; padding:2rem; color:#9ca3af; }
|
||||
.pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; }
|
||||
|
||||
/* Modal */
|
||||
.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5);
|
||||
display:flex; align-items:flex-start; justify-content:center;
|
||||
z-index:9999; padding:2rem 1rem; overflow-y:auto; }
|
||||
.pfm-modal { background:#fff; border-radius:.75rem; width:100%; max-width:56rem;
|
||||
box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; }
|
||||
.pfm-modal-header { display:flex; align-items:center; justify-content:space-between;
|
||||
padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; }
|
||||
.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; }
|
||||
.pfm-modal-body { padding:1.25rem; overflow-y:auto; max-height:70vh; }
|
||||
.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem;
|
||||
padding:.875rem 1.25rem; border-top:1px solid #e5e7eb;
|
||||
background:#f9fafb; border-radius:0 0 .75rem .75rem; }
|
||||
|
||||
/* Entry table */
|
||||
.pfm-table-wrap { overflow-x:auto; }
|
||||
.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
|
||||
.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem;
|
||||
font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb;
|
||||
white-space:nowrap; position:sticky; top:0; }
|
||||
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid #f3f4f6;
|
||||
vertical-align:top; }
|
||||
.pfm-table tr.today-row td { background:#fef9c3; }
|
||||
.pfm-table td input, .pfm-table td textarea {
|
||||
width:100%; border:1px solid #d1d5db; border-radius:.25rem;
|
||||
padding:.25rem .375rem; font-size:.8125rem; font-family:inherit;
|
||||
resize:vertical; background:#fff; }
|
||||
.pfm-table td input:focus, .pfm-table td textarea:focus {
|
||||
outline:none; border-color:#3b82f6; }
|
||||
.pfm-day-col { width:3rem; text-align:center; font-weight:600;
|
||||
color:#6b7280; white-space:nowrap; }
|
||||
.pfm-pagination { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-top:.75rem; font-size:.8125rem; color:#6b7280; }
|
||||
.pfm-page-jump { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; }
|
||||
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db;
|
||||
border-radius:.25rem; text-align:center; }
|
||||
|
||||
/* Form in create modal */
|
||||
.pfm-field { margin-bottom:.875rem; }
|
||||
.pfm-field label { display:block; font-size:.875rem; font-weight:500;
|
||||
color:#374151; margin-bottom:.25rem; }
|
||||
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db;
|
||||
border-radius:.375rem; font-size:.875rem; }
|
||||
.pfm-field input:focus { outline:none; border-color:#3b82f6; }
|
||||
.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; }
|
||||
.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; }
|
||||
|
||||
/* Delete danger box */
|
||||
.pfm-danger-box { background:#fef2f2; border:1px solid #fecaca;
|
||||
border-radius:.5rem; padding:.875rem; font-size:.875rem;
|
||||
color:#991b1b; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// ─── Safe HTML helper ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse html in a sandboxed DOMParser document (scripts never execute) and
|
||||
* replace target's children with the result. All dynamic values in html
|
||||
* must be escaped by the caller before passing here.
|
||||
*/
|
||||
function safeSetHTML(target, html) {
|
||||
target.textContent = '';
|
||||
// createContextualFragment parses html relative to the document context
|
||||
// without executing scripts — a widely recognised safe insertion method.
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
// ─── Per-instance state ───────────────────────────────────────────────────
|
||||
|
||||
const _state = new Map(); // fieldId → { pluginId, actions, createFields, files, page, entriesPerPage, modal }
|
||||
|
||||
function getState(fieldId) {
|
||||
if (!_state.has(fieldId)) _state.set(fieldId, {
|
||||
pluginId: '', actions: {}, createFields: [], uploadHint: '',
|
||||
directoryLabel: '', files: [], page: 1, entriesPerPage: 20,
|
||||
currentModal: null
|
||||
});
|
||||
return _state.get(fieldId);
|
||||
}
|
||||
|
||||
// ─── API helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async function callAction(pluginId, actionId, params = {}) {
|
||||
const resp = await fetch('/api/v3/plugins/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plugin_id: pluginId, action_id: actionId, params })
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
function notify(msg, type) {
|
||||
if (window.showNotification) window.showNotification(msg, type);
|
||||
else console.log(`[PFM][${type}] ${msg}`);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(s ?? '');
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1024).toFixed(2) + ' KB';
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
try { return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); }
|
||||
catch { return iso; }
|
||||
}
|
||||
|
||||
// ─── Core: load files ─────────────────────────────────────────────────────
|
||||
|
||||
async function loadFiles(fieldId) {
|
||||
const st = getState(fieldId);
|
||||
const root = document.getElementById(`${fieldId}_pfm`);
|
||||
if (!root) return;
|
||||
const grid = root.querySelector('.pfm-grid');
|
||||
if (grid) safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>');
|
||||
|
||||
const data = await callAction(st.pluginId, st.actions.list).catch(() => null);
|
||||
if (!data || data.status !== 'success') {
|
||||
if (grid) safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-exclamation-circle"></i>Failed to load files.</div>');
|
||||
return;
|
||||
}
|
||||
st.files = data.files || [];
|
||||
renderCards(fieldId);
|
||||
}
|
||||
|
||||
// ─── Card grid ────────────────────────────────────────────────────────────
|
||||
|
||||
function renderCards(fieldId) {
|
||||
const st = getState(fieldId);
|
||||
const root = document.getElementById(`${fieldId}_pfm`);
|
||||
if (!root) return;
|
||||
const grid = root.querySelector('.pfm-grid');
|
||||
if (!grid) return;
|
||||
|
||||
if (!st.files.length) {
|
||||
safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-folder-open"></i>No files yet. Create or upload one.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing delegated listener before re-render
|
||||
if (st._gridClickHandler) grid.removeEventListener('click', st._gridClickHandler);
|
||||
if (st._gridChangeHandler) grid.removeEventListener('change', st._gridChangeHandler);
|
||||
|
||||
// Event delegation: handles edit/delete/toggle via data attributes so
|
||||
// filenames and category names are never interpolated into JS string literals.
|
||||
st._gridClickHandler = function(e) {
|
||||
const btn = e.target.closest('[data-pfm-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.pfmAction;
|
||||
const fId = btn.dataset.pfmField;
|
||||
if (action === 'edit') window._pfmOpenEdit(fId, btn.dataset.pfmFile);
|
||||
if (action === 'delete') window._pfmOpenDelete(fId, btn.dataset.pfmFile);
|
||||
};
|
||||
st._gridChangeHandler = function(e) {
|
||||
const inp = e.target.closest('[data-pfm-action="toggle"]');
|
||||
if (!inp) return;
|
||||
window._pfmToggle(inp.dataset.pfmField, inp.dataset.pfmCategory, inp.checked);
|
||||
};
|
||||
grid.addEventListener('click', st._gridClickHandler);
|
||||
grid.addEventListener('change', st._gridChangeHandler);
|
||||
|
||||
// Build cards with DOM methods so no user-derived data flows through innerHTML.
|
||||
grid.textContent = '';
|
||||
const frag = document.createDocumentFragment();
|
||||
st.files.forEach(function(f) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'pfm-card' + (f.enabled === false ? ' disabled' : '');
|
||||
card.dataset.filename = f.filename;
|
||||
card.dataset.category = f.category_name;
|
||||
|
||||
// Top row: label + optional toggle
|
||||
const top = document.createElement('div');
|
||||
top.className = 'pfm-card-top';
|
||||
const lbl = document.createElement('span');
|
||||
lbl.className = 'pfm-toggle-label';
|
||||
lbl.textContent = f.enabled !== false ? 'Enabled' : 'Disabled';
|
||||
top.appendChild(lbl);
|
||||
if (st.actions.toggle) {
|
||||
const tglLabel = document.createElement('label');
|
||||
tglLabel.className = 'pfm-toggle-cb';
|
||||
tglLabel.title = f.enabled !== false ? 'Click to disable' : 'Click to enable';
|
||||
const tglInput = document.createElement('input');
|
||||
tglInput.type = 'checkbox';
|
||||
tglInput.checked = f.enabled !== false;
|
||||
tglInput.dataset.pfmAction = 'toggle';
|
||||
tglInput.dataset.pfmField = fieldId;
|
||||
tglInput.dataset.pfmCategory = f.category_name;
|
||||
const tglSlider = document.createElement('span');
|
||||
tglSlider.className = 'pfm-toggle-slider';
|
||||
tglLabel.appendChild(tglInput);
|
||||
tglLabel.appendChild(tglSlider);
|
||||
top.appendChild(tglLabel);
|
||||
}
|
||||
card.appendChild(top);
|
||||
|
||||
// Icon (static markup)
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'pfm-card-icon';
|
||||
icon.innerHTML = '<i class="fas fa-file-code"></i>';
|
||||
card.appendChild(icon);
|
||||
|
||||
// Name & meta — textContent avoids any HTML injection
|
||||
const name = document.createElement('div');
|
||||
name.className = 'pfm-card-name';
|
||||
name.textContent = f.display_name || f.filename;
|
||||
card.appendChild(name);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'pfm-card-meta';
|
||||
meta.appendChild(document.createTextNode(f.filename));
|
||||
meta.appendChild(document.createElement('br'));
|
||||
if (f.entry_count != null) {
|
||||
meta.appendChild(document.createTextNode(f.entry_count + ' entries · ' + formatSize(f.size)));
|
||||
}
|
||||
meta.appendChild(document.createElement('br'));
|
||||
meta.appendChild(document.createTextNode(formatDate(f.modified)));
|
||||
card.appendChild(meta);
|
||||
|
||||
// Action buttons
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'pfm-card-actions';
|
||||
if (st.actions.get && st.actions.save) {
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'pfm-btn pfm-btn-primary';
|
||||
editBtn.dataset.pfmAction = 'edit';
|
||||
editBtn.dataset.pfmField = fieldId;
|
||||
editBtn.dataset.pfmFile = f.filename;
|
||||
editBtn.innerHTML = '<i class="fas fa-edit"></i> Edit'; // static
|
||||
actions.appendChild(editBtn);
|
||||
}
|
||||
if (st.actions.delete) {
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'pfm-btn pfm-btn-danger pfm-btn-sm';
|
||||
delBtn.dataset.pfmAction = 'delete';
|
||||
delBtn.dataset.pfmField = fieldId;
|
||||
delBtn.dataset.pfmFile = f.filename;
|
||||
delBtn.innerHTML = '<i class="fas fa-trash"></i>'; // static
|
||||
actions.appendChild(delBtn);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
frag.appendChild(card);
|
||||
});
|
||||
grid.appendChild(frag);
|
||||
}
|
||||
|
||||
// ─── Edit modal ───────────────────────────────────────────────────────────
|
||||
|
||||
window._pfmOpenEdit = async function (fieldId, filename) {
|
||||
const st = getState(fieldId);
|
||||
const overlay = createOverlay(fieldId);
|
||||
// Build modal using DOM methods so filename never enters a JS string literal.
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pfm-modal';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||
<i class="fas fa-save mr-1"></i>Save
|
||||
</button>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
// Bind events after DOM insertion — filename captured in closure, not in HTML.
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
|
||||
|
||||
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
|
||||
const body = document.getElementById(`${fieldId}_edit_body`);
|
||||
if (!data || data.status !== 'success' || !body) {
|
||||
if (body) safeSetHTML(body, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = data.content || data.data || {};
|
||||
st._editFilename = filename;
|
||||
|
||||
if (isTabular(content)) {
|
||||
// Table path: track cell edits live in _editData
|
||||
st._editData = content;
|
||||
renderEntryTable(fieldId, body, content);
|
||||
} else {
|
||||
// Textarea path: _editData stays null; save() reads from the <textarea>
|
||||
st._editData = null;
|
||||
safeSetHTML(body, `
|
||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
||||
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
||||
}
|
||||
};
|
||||
|
||||
function isTabular(data) {
|
||||
if (typeof data !== 'object' || Array.isArray(data)) return false;
|
||||
const keys = Object.keys(data);
|
||||
if (!keys.length) return false;
|
||||
const first = data[keys[0]];
|
||||
if (typeof first !== 'object' || Array.isArray(first)) return false;
|
||||
const entryKeys = Object.keys(first);
|
||||
return entryKeys.length > 0 && entryKeys.length <= 8;
|
||||
}
|
||||
|
||||
function renderEntryTable(fieldId, container, content) {
|
||||
const st = getState(fieldId);
|
||||
const entries = Object.entries(content).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
|
||||
if (!entries.length) { container.textContent = 'No entries.'; return; }
|
||||
|
||||
const cols = Object.keys(entries[0][1]);
|
||||
const MS_PER_DAY = 86400 * 1000; // eslint-disable-line no-magic-numbers -- 86400s/day is not magic
|
||||
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / MS_PER_DAY);
|
||||
const total = entries.length;
|
||||
const perPage = st.entriesPerPage;
|
||||
|
||||
function buildPage(page) {
|
||||
const start = (page - 1) * perPage; // eslint-disable-line no-magic-numbers
|
||||
const pageEntries = entries.slice(start, start + perPage);
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
safeSetHTML(container, `
|
||||
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
|
||||
${total} entries total
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
|
||||
onclick="(function(){const targetPage=Math.ceil(${todayDoy}/${perPage});window._pfmTablePage('${fieldId}',targetPage);setTimeout(function(){const row=document.querySelector('tr[data-day=\\'${todayDoy}\\']');if(row)row.scrollIntoView({block:'center'});},60);})()">
|
||||
<i class="fas fa-calendar-day"></i> Jump to today (day ${todayDoy})
|
||||
</button>
|
||||
</div>
|
||||
<div id="${fieldId}_tbl_wrap" class="pfm-table-wrap" style="max-height:52vh;overflow-y:auto;">
|
||||
<table class="pfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="pfm-day-col">Day</th>
|
||||
${cols.map(c => `<th>${escHtml(c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()))}</th>`).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${pageEntries.map(([day, val]) => `
|
||||
<tr data-day="${day}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
|
||||
<td class="pfm-day-col" style="user-select:none;">${escHtml(day)}</td>
|
||||
${cols.map(col => {
|
||||
const v = val[col] ?? '';
|
||||
const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content';
|
||||
return isLong
|
||||
? `<td><textarea data-day="${day}" data-col="${escHtml(col)}" rows="2"
|
||||
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"
|
||||
>${escHtml(String(v))}</textarea></td>`
|
||||
: `<td><input type="text" data-day="${day}" data-col="${escHtml(col)}"
|
||||
value="${escHtml(String(v))}"
|
||||
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"></td>`;
|
||||
}).join('')}
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pfm-pagination">
|
||||
<span>Page ${page} of ${totalPages}</span>
|
||||
<div class="pfm-page-jump">
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
||||
${page <= 1 ? 'disabled' : ''}
|
||||
onclick="window._pfmTablePage('${fieldId}',${page - 1})">‹ Prev</button>
|
||||
<span>Go to</span>
|
||||
<input type="number" min="1" max="${totalPages}" value="${page}"
|
||||
onchange="window._pfmTablePage('${fieldId}',+this.value)">
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
||||
${page >= totalPages ? 'disabled' : ''}
|
||||
onclick="window._pfmTablePage('${fieldId}',${page + 1})">Next ›</button>
|
||||
</div>
|
||||
</div>`;
|
||||
st._tablePage = page;
|
||||
st._tableEntries = entries;
|
||||
st._tableCols = cols;
|
||||
}
|
||||
|
||||
// Store buildPage in per-instance state so multiple instances don't
|
||||
// clobber each other's pagination via a shared global.
|
||||
st._buildPage = buildPage;
|
||||
buildPage(st._tablePage || 1);
|
||||
}
|
||||
|
||||
// Global dispatcher — resolves the per-instance buildPage from state so
|
||||
// multiple plugin-file-manager instances don't clobber each other.
|
||||
window._pfmTablePage = function (fId, p) {
|
||||
const s = getState(fId);
|
||||
if (s._buildPage) {
|
||||
const total = s._tableEntries ? s._tableEntries.length : 0;
|
||||
const totalP = Math.ceil(total / s.entriesPerPage) || 1;
|
||||
s._buildPage(Math.max(1, Math.min(p, totalP)));
|
||||
}
|
||||
};
|
||||
|
||||
window._pfmCellEdit = function (fieldId, day, col, value) {
|
||||
const st = getState(fieldId);
|
||||
if (st._editData && st._editData[day]) st._editData[day][col] = value;
|
||||
};
|
||||
|
||||
window._pfmSave = async function (fieldId, filename) {
|
||||
const st = getState(fieldId);
|
||||
const saveBtn = document.getElementById(`${fieldId}_save_btn`);
|
||||
let content;
|
||||
|
||||
// Try getting from inline table data first, then textarea fallback
|
||||
if (st._editData) {
|
||||
content = st._editData;
|
||||
} else {
|
||||
const ta = document.getElementById(`${fieldId}_json_ta`);
|
||||
if (!ta) return;
|
||||
try { content = JSON.parse(ta.value); }
|
||||
catch (e) {
|
||||
const errEl = document.getElementById(`${fieldId}_json_err`);
|
||||
if (errEl) errEl.textContent = 'Invalid JSON: ' + e.message;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (saveBtn) { saveBtn.disabled = true; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Saving…'));})(saveBtn); }
|
||||
|
||||
const result = await callAction(st.pluginId, st.actions.save, {
|
||||
filename, content: JSON.stringify(content)
|
||||
}).catch(() => ({ status: 'error', message: 'Network error' }));
|
||||
|
||||
if (saveBtn) { saveBtn.disabled = false; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-save mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Save'));})(saveBtn); }
|
||||
|
||||
if (result.status === 'success') {
|
||||
notify('File saved successfully', 'success');
|
||||
window._pfmCloseModal(fieldId);
|
||||
await loadFiles(fieldId);
|
||||
} else {
|
||||
notify('Save failed: ' + (result.message || 'Unknown error'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Delete modal ─────────────────────────────────────────────────────────
|
||||
|
||||
window._pfmOpenDelete = function (fieldId, filename) {
|
||||
const overlay = createOverlay(fieldId);
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pfm-modal';
|
||||
modal.style.maxWidth = '28rem';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
<div class="pfm-danger-box">
|
||||
<strong>${escHtml(filename)}</strong> will be permanently deleted and removed
|
||||
from the plugin configuration. This cannot be undone.
|
||||
</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
</button>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
|
||||
};
|
||||
|
||||
window._pfmConfirmDelete = async function (fieldId, filename) {
|
||||
const st = getState(fieldId);
|
||||
const result = await callAction(st.pluginId, st.actions.delete, { filename })
|
||||
.catch(() => ({ status: 'error', message: 'Network error' }));
|
||||
if (result.status === 'success') {
|
||||
notify('File deleted', 'success');
|
||||
window._pfmCloseModal(fieldId);
|
||||
await loadFiles(fieldId);
|
||||
} else {
|
||||
notify('Delete failed: ' + (result.message || ''), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Create modal ─────────────────────────────────────────────────────────
|
||||
|
||||
window._pfmOpenCreate = function (fieldId) {
|
||||
const st = getState(fieldId);
|
||||
const fields = st.createFields;
|
||||
const overlay = createOverlay(fieldId);
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pfm-modal';
|
||||
modal.style.maxWidth = '32rem';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
||||
${fields.map(f => `
|
||||
<div class="pfm-field">
|
||||
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
||||
<input type="text" id="${escHtml(fieldId)}_cf_${escHtml(f.key)}"
|
||||
placeholder="${escHtml(f.placeholder || '')}"
|
||||
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
|
||||
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||
<i class="fas fa-plus mr-1"></i>Create
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
|
||||
};
|
||||
|
||||
window._pfmConfirmCreate = async function (fieldId) {
|
||||
const st = getState(fieldId);
|
||||
const errEl = document.getElementById(`${fieldId}_create_err`);
|
||||
const btn = document.getElementById(`${fieldId}_create_btn`);
|
||||
const params = {};
|
||||
|
||||
for (const f of st.createFields) {
|
||||
const inp = document.getElementById(`${fieldId}_cf_${f.key}`);
|
||||
if (!inp) continue;
|
||||
const val = inp.value.trim();
|
||||
// Client-side pattern validation omitted — server-side create-file script validates.
|
||||
params[f.key] = val;
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = true; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Creating…'));})(btn); }
|
||||
if (errEl) errEl.textContent = '';
|
||||
|
||||
const result = await callAction(st.pluginId, st.actions.create, params)
|
||||
.catch(() => ({ status: 'error', message: 'Network error' }));
|
||||
|
||||
if (btn) { btn.disabled = false; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-plus mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Create'));})(btn); }
|
||||
|
||||
if (result.status === 'success') {
|
||||
notify('File created', 'success');
|
||||
window._pfmCloseModal(fieldId);
|
||||
await loadFiles(fieldId);
|
||||
} else {
|
||||
if (errEl) errEl.textContent = result.message || 'Create failed';
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Toggle ───────────────────────────────────────────────────────────────
|
||||
|
||||
window._pfmToggle = async function (fieldId, categoryName, enabled) {
|
||||
const st = getState(fieldId);
|
||||
const result = await callAction(st.pluginId, st.actions.toggle, { category_name: categoryName, enabled })
|
||||
.catch(() => ({ status: 'error' }));
|
||||
if (result.status === 'success') {
|
||||
notify(enabled ? `${categoryName} enabled` : `${categoryName} disabled`, 'success');
|
||||
await loadFiles(fieldId);
|
||||
} else {
|
||||
notify('Toggle failed', 'error');
|
||||
await loadFiles(fieldId); // revert UI
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Upload ───────────────────────────────────────────────────────────────
|
||||
|
||||
window._pfmUpload = async function (fieldId, file) {
|
||||
const st = getState(fieldId);
|
||||
const notifyFn = window.showNotification || console.log;
|
||||
if (!file.name.toLowerCase().endsWith('.json')) {
|
||||
notifyFn('Only .json files can be uploaded', 'error'); return;
|
||||
}
|
||||
let content;
|
||||
try { content = await file.text(); JSON.parse(content); }
|
||||
catch { notifyFn('File contains invalid JSON', 'error'); return; }
|
||||
|
||||
const result = await callAction(st.pluginId, st.actions.upload, {
|
||||
filename: file.name, content
|
||||
}).catch(() => ({ status: 'error', message: 'Network error' }));
|
||||
|
||||
if (result.status === 'success') {
|
||||
notify('File uploaded: ' + (result.filename || file.name), 'success');
|
||||
await loadFiles(fieldId);
|
||||
} else {
|
||||
notify('Upload failed: ' + (result.message || ''), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Modal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function createOverlay(fieldId) {
|
||||
window._pfmCloseModal(fieldId); // close any open modal first
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'pfm-overlay';
|
||||
overlay.id = `${fieldId}_pfm_overlay`;
|
||||
// Close on backdrop click
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) window._pfmCloseModal(fieldId); });
|
||||
document.body.appendChild(overlay);
|
||||
getState(fieldId).currentModal = overlay;
|
||||
return overlay;
|
||||
}
|
||||
|
||||
window._pfmCloseModal = function (fieldId) {
|
||||
const st = getState(fieldId);
|
||||
if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; }
|
||||
st._editData = null;
|
||||
st._editFilename = null;
|
||||
};
|
||||
|
||||
// ─── Widget registration ──────────────────────────────────────────────────
|
||||
|
||||
window.LEDMatrixWidgets.register('plugin-file-manager', {
|
||||
name: 'Plugin File Manager Widget',
|
||||
version: '1.0.0',
|
||||
|
||||
render: function (container, config, value, options) {
|
||||
const fieldId = (options.fieldId || container.id || 'pfm').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
const wc = config['x-widget-config'] || {};
|
||||
const actions = wc.actions || {};
|
||||
const pluginId = options.pluginId || '';
|
||||
|
||||
const st = getState(fieldId);
|
||||
Object.assign(st, {
|
||||
pluginId,
|
||||
actions,
|
||||
createFields: wc.create_fields || [],
|
||||
uploadHint: wc.upload_hint || 'Upload JSON files',
|
||||
directoryLabel: wc.directory_label || ''
|
||||
});
|
||||
|
||||
safeSetHTML(container, `
|
||||
<div class="pfm-root" id="${fieldId}_pfm">
|
||||
<div class="pfm-header">
|
||||
<div>
|
||||
<div class="pfm-title">File Explorer</div>
|
||||
${st.directoryLabel ? `<div class="pfm-dir">Manage files in <code>${escHtml(st.directoryLabel)}</code></div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:.375rem;">
|
||||
${actions.create ? `
|
||||
<button class="pfm-btn pfm-btn-create"
|
||||
onclick="window._pfmOpenCreate('${fieldId}')">
|
||||
<i class="fas fa-plus mr-1"></i>New File
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${actions.upload ? `
|
||||
<div class="pfm-upload" id="${fieldId}_upload_zone"
|
||||
onclick="document.getElementById('${fieldId}_file_input').click()"
|
||||
ondragover="event.preventDefault();this.classList.add('dragover')"
|
||||
ondragleave="this.classList.remove('dragover')"
|
||||
ondrop="this.classList.remove('dragover');event.preventDefault();
|
||||
if(event.dataTransfer.files[0])window._pfmUpload('${fieldId}',event.dataTransfer.files[0])">
|
||||
<input type="file" id="${fieldId}_file_input" accept=".json"
|
||||
style="display:none"
|
||||
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size:1.5rem;color:#9ca3af;"></i>
|
||||
<p>Drag and drop or click to upload</p>
|
||||
<small>${escHtml(st.uploadHint)}</small>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="pfm-grid">
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
loadFiles(fieldId);
|
||||
},
|
||||
|
||||
getValue: function () { return null; }, // file ops are immediate; nothing to submit
|
||||
setValue: function (fieldId) { loadFiles(fieldId); }
|
||||
});
|
||||
|
||||
console.log('[PluginFileManager] plugin-file-manager widget registered');
|
||||
})();
|
||||
166
web_interface/static/v3/js/widgets/time-picker.js
Normal file
166
web_interface/static/v3/js/widgets/time-picker.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* LEDMatrix Time Picker Widget
|
||||
*
|
||||
* Single time selection using the browser's native time input.
|
||||
* Returns a string in HH:MM (24-hour) format.
|
||||
*
|
||||
* Schema example:
|
||||
* {
|
||||
* "target_time": {
|
||||
* "type": "string",
|
||||
* "x-widget": "time-picker",
|
||||
* "default": "00:00",
|
||||
* "x-options": {
|
||||
* "placeholder": "Select time",
|
||||
* "clearable": true
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @module TimePickerWidget
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null;
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (base) return base.escapeHtml(text);
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeId(id) {
|
||||
if (base) return base.sanitizeId(id);
|
||||
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function triggerChange(fieldId, value) {
|
||||
if (base) {
|
||||
base.triggerChange(fieldId, value);
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('widget-change', {
|
||||
detail: { fieldId, value },
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetHTML(target, html) {
|
||||
target.textContent = '';
|
||||
// createContextualFragment parses html relative to the document context
|
||||
// without executing scripts — a widely recognised safe insertion method.
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
window.LEDMatrixWidgets.register('time-picker', {
|
||||
name: 'Time Picker Widget',
|
||||
version: '1.0.0',
|
||||
|
||||
render: function(container, config, value, options) {
|
||||
const fieldId = sanitizeId(options.fieldId || container.id || 'time_picker');
|
||||
const xOptions = config['x-options'] || config['x_options'] || {};
|
||||
const placeholder = xOptions.placeholder || '';
|
||||
const clearable = xOptions.clearable === true;
|
||||
const disabled = xOptions.disabled === true;
|
||||
const required = xOptions.required === true;
|
||||
|
||||
const currentValue = value || '';
|
||||
|
||||
let html = `<div id="${fieldId}_widget" class="time-picker-widget" data-field-id="${fieldId}">`;
|
||||
html += '<div class="flex items-center">';
|
||||
html += `
|
||||
<div class="relative flex-1">
|
||||
<input type="time"
|
||||
id="${fieldId}_input"
|
||||
name="${escapeHtml(options.name || fieldId)}"
|
||||
value="${escapeHtml(currentValue)}"
|
||||
${placeholder ? `placeholder="${escapeHtml(placeholder)}"` : ''}
|
||||
${disabled ? 'disabled' : ''}
|
||||
${required ? 'required' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
|
||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<i class="fas fa-clock text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (clearable && !disabled) {
|
||||
html += `
|
||||
<button type="button"
|
||||
id="${fieldId}_clear"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')"
|
||||
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
||||
title="Clear">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
|
||||
html += '</div>';
|
||||
|
||||
safeSetHTML(container, html);
|
||||
},
|
||||
|
||||
getValue: function(fieldId) {
|
||||
const safeId = sanitizeId(fieldId);
|
||||
const input = document.getElementById(`${safeId}_input`);
|
||||
return input ? input.value : '';
|
||||
},
|
||||
|
||||
setValue: function(fieldId, value) {
|
||||
const safeId = sanitizeId(fieldId);
|
||||
const input = document.getElementById(`${safeId}_input`);
|
||||
const clearBtn = document.getElementById(`${safeId}_clear`);
|
||||
if (input) input.value = value || '';
|
||||
if (clearBtn) clearBtn.classList.toggle('hidden', !value);
|
||||
},
|
||||
|
||||
validate: function(fieldId) {
|
||||
const safeId = sanitizeId(fieldId);
|
||||
const input = document.getElementById(`${safeId}_input`);
|
||||
const errorEl = document.getElementById(`${safeId}_error`);
|
||||
if (!input) return { valid: true, errors: [] };
|
||||
const isValid = input.checkValidity();
|
||||
if (errorEl) {
|
||||
if (!isValid) {
|
||||
errorEl.textContent = input.validationMessage;
|
||||
errorEl.classList.remove('hidden');
|
||||
input.classList.add('border-red-500');
|
||||
} else {
|
||||
errorEl.classList.add('hidden');
|
||||
input.classList.remove('border-red-500');
|
||||
}
|
||||
}
|
||||
return { valid: isValid, errors: isValid ? [] : [input.validationMessage] };
|
||||
},
|
||||
|
||||
handlers: {
|
||||
onChange: function(fieldId) {
|
||||
const widget = window.LEDMatrixWidgets.get('time-picker');
|
||||
const safeId = sanitizeId(fieldId);
|
||||
const clearBtn = document.getElementById(`${safeId}_clear`);
|
||||
const value = widget.getValue(fieldId);
|
||||
if (clearBtn) clearBtn.classList.toggle('hidden', !value);
|
||||
widget.validate(fieldId);
|
||||
triggerChange(fieldId, value);
|
||||
},
|
||||
|
||||
onClear: function(fieldId) {
|
||||
const widget = window.LEDMatrixWidgets.get('time-picker');
|
||||
widget.setValue(fieldId, '');
|
||||
widget.validate(fieldId); // refresh required/error state
|
||||
triggerChange(fieldId, '');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[TimePickerWidget] Time picker widget registered');
|
||||
})();
|
||||
@@ -1442,9 +1442,14 @@ function renderInstalledPlugins(plugins) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to escape attributes for use in HTML
|
||||
// Helper function to escape values for use in HTML attributes
|
||||
const escapeAttr = (text) => {
|
||||
return (text || '').replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
return (text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
};
|
||||
|
||||
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
|
||||
@@ -3441,6 +3446,28 @@ function generateFieldHtml(key, prop, value, prefix = '') {
|
||||
html += `<option value="${option}" ${selected}>${option}</option>`;
|
||||
});
|
||||
html += `</select>`;
|
||||
} else if (prop['x-widget'] === 'json-file-manager') {
|
||||
// Reusable JSON file manager widget (no CDN, keyboard shortcuts, configurable actions)
|
||||
const widgetConfig = prop['x-widget-config'] || {};
|
||||
const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
|
||||
const safeFieldId = (fullKey || 'file_manager').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
html += `<div id="${safeFieldId}_jfm_mount"></div>`;
|
||||
|
||||
setTimeout(() => {
|
||||
const mount = document.getElementById(`${safeFieldId}_jfm_mount`);
|
||||
if (!mount) return;
|
||||
// Destroy the previous instance for this mount only — leave other instances intact
|
||||
window.__jfmInstances = window.__jfmInstances || {};
|
||||
const prev = window.__jfmInstances[safeFieldId];
|
||||
if (prev?._destroy) prev._destroy();
|
||||
if (typeof JsonFileManager !== 'undefined') {
|
||||
window.__jfmInstances[safeFieldId] = new JsonFileManager(mount, widgetConfig, pluginId);
|
||||
} else {
|
||||
window.__jfmInstances[safeFieldId] = null;
|
||||
mount.innerHTML = '<p style="color:#dc2626;font-size:.875rem;">json-file-manager widget not loaded. Check base.html includes json-file-manager.js.</p>';
|
||||
}
|
||||
}, 150);
|
||||
} else if (prop['x-widget'] === 'custom-html') {
|
||||
// Custom HTML widget - load HTML from plugin directory
|
||||
const htmlFile = prop['x-html-file'];
|
||||
@@ -4507,6 +4534,8 @@ function syncFormToJson() {
|
||||
// Deep merge with existing config to preserve nested structures
|
||||
function deepMerge(target, source) {
|
||||
for (const key in source) {
|
||||
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
|
||||
target[key] = {};
|
||||
@@ -7473,17 +7502,28 @@ setTimeout(function() {
|
||||
console.log('installed-plugins-grid not found yet, will retry via event listeners');
|
||||
}
|
||||
|
||||
// Also try to attach install button handler after a delay (fallback)
|
||||
// Also try to attach install button handler after a delay (fallback).
|
||||
// Only run if the install button element is already in the DOM (i.e. the
|
||||
// plugins partial has been loaded); otherwise the htmx:afterSettle listener
|
||||
// below handles it when the tab is first visited.
|
||||
setTimeout(() => {
|
||||
if (typeof window.attachInstallButtonHandler === 'function') {
|
||||
console.log('[FALLBACK] Attempting to attach install button handler...');
|
||||
if (typeof window.attachInstallButtonHandler === 'function' &&
|
||||
document.getElementById('install-plugin-from-url')) {
|
||||
window.attachInstallButtonHandler();
|
||||
} else {
|
||||
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
|
||||
}
|
||||
}, 500);
|
||||
}, 200);
|
||||
|
||||
// Re-run install button wiring after HTMX settles the plugins tab content.
|
||||
// Guard with element check so it only fires when the plugins partial is in the DOM,
|
||||
// preventing spurious warnings on other tab loads.
|
||||
document.addEventListener('htmx:afterSettle', function() {
|
||||
if (document.getElementById('install-plugin-from-url') &&
|
||||
typeof window.attachInstallButtonHandler === 'function') {
|
||||
window.attachInstallButtonHandler();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Starlark Apps Integration ──────────────────────────────────────────────
|
||||
|
||||
(function() {
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
setTimeout(function() {
|
||||
if (typeof htmx !== 'undefined') {
|
||||
console.log('HTMX loaded from fallback');
|
||||
window.dispatchEvent(new Event('htmx:ready'));
|
||||
// Load extensions after core loads
|
||||
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
||||
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
||||
@@ -152,6 +153,7 @@
|
||||
}
|
||||
} else {
|
||||
console.log('HTMX loaded successfully');
|
||||
window.dispatchEvent(new Event('htmx:ready'));
|
||||
// Load extensions after core loads
|
||||
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
||||
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
||||
@@ -349,6 +351,19 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
var target = event.detail.target;
|
||||
var trigger = target.getAttribute('hx-trigger') || '';
|
||||
if (trigger.includes('loadtab')) {
|
||||
target.setAttribute('data-loaded', 'true');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
||||
@@ -411,6 +426,9 @@
|
||||
.then(html => {
|
||||
clearTimeout(timeout);
|
||||
content.innerHTML = html;
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.process(content);
|
||||
}
|
||||
// Trigger full initialization chain
|
||||
if (window.pluginManager) {
|
||||
window.pluginManager.initialized = false;
|
||||
@@ -430,7 +448,7 @@
|
||||
}
|
||||
|
||||
// Fallback if HTMX doesn't load within 5 seconds
|
||||
setTimeout(() => {
|
||||
var _pluginsFallbackTimer = setTimeout(() => {
|
||||
if (typeof htmx === 'undefined') {
|
||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
||||
// Load plugins tab content directly regardless of active tab,
|
||||
@@ -438,6 +456,7 @@
|
||||
loadPluginsDirect();
|
||||
}
|
||||
}, 5000);
|
||||
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
|
||||
</script>
|
||||
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
||||
<script>
|
||||
@@ -847,7 +866,7 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Custom v3 styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}?v=20260216b">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
|
||||
</head>
|
||||
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
@@ -1010,7 +1029,7 @@
|
||||
<div id="tab-content" class="space-y-6">
|
||||
<!-- Overview tab -->
|
||||
<div x-show="activeTab === 'overview'" x-transition>
|
||||
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="revealed" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
||||
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1030,6 +1049,9 @@
|
||||
.then(html => {
|
||||
overviewContent.innerHTML = html;
|
||||
overviewContent.setAttribute('data-loaded', 'true');
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.process(overviewContent);
|
||||
}
|
||||
// Re-initialize Alpine.js for the new content
|
||||
if (window.Alpine) {
|
||||
window.Alpine.initTree(overviewContent);
|
||||
@@ -1058,7 +1080,7 @@
|
||||
});
|
||||
|
||||
// Also try direct load if HTMX doesn't load within 5 seconds
|
||||
setTimeout(() => {
|
||||
var _overviewFallbackTimer = setTimeout(() => {
|
||||
if (typeof htmx === 'undefined') {
|
||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
||||
const appElement = document.querySelector('[x-data="app()"]');
|
||||
@@ -1070,11 +1092,12 @@
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
|
||||
</script>
|
||||
|
||||
<!-- General tab -->
|
||||
<div x-show="activeTab === 'general'" x-transition>
|
||||
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1092,7 +1115,7 @@
|
||||
<div x-show="activeTab === 'wifi'" x-transition>
|
||||
<div id="wifi-content"
|
||||
hx-get="/v3/partials/wifi"
|
||||
hx-trigger="revealed"
|
||||
hx-trigger="loadtab"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::htmx:response-error="loadWifiDirect()">
|
||||
<div class="animate-pulse">
|
||||
@@ -1143,7 +1166,7 @@
|
||||
|
||||
<!-- Schedule tab -->
|
||||
<div x-show="activeTab === 'schedule'" x-transition>
|
||||
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1159,7 +1182,7 @@
|
||||
|
||||
<!-- Display tab -->
|
||||
<div x-show="activeTab === 'display'" x-transition>
|
||||
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1174,7 +1197,7 @@
|
||||
|
||||
<!-- Backup & Restore tab -->
|
||||
<div x-show="activeTab === 'backup-restore'" x-transition>
|
||||
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1186,7 +1209,7 @@
|
||||
|
||||
<!-- Config Editor tab -->
|
||||
<div x-show="activeTab === 'config-editor'" x-transition>
|
||||
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1201,7 +1224,7 @@
|
||||
|
||||
<!-- Plugins tab -->
|
||||
<div x-show="activeTab === 'plugins'" x-transition>
|
||||
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="revealed" hx-swap="innerHTML"
|
||||
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
|
||||
hx-on::response-error="loadPluginsDirect()">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
@@ -1218,7 +1241,7 @@
|
||||
|
||||
<!-- Fonts tab -->
|
||||
<div x-show="activeTab === 'fonts'" x-transition>
|
||||
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1233,7 +1256,7 @@
|
||||
|
||||
<!-- Logs tab -->
|
||||
<div x-show="activeTab === 'logs'" x-transition>
|
||||
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1245,7 +1268,7 @@
|
||||
|
||||
<!-- Cache tab -->
|
||||
<div x-show="activeTab === 'cache'" x-transition>
|
||||
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1257,7 +1280,7 @@
|
||||
|
||||
<!-- Operation History tab -->
|
||||
<div x-show="activeTab === 'operation-history'" x-transition>
|
||||
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1346,34 +1369,64 @@
|
||||
|
||||
<!-- SSE connection for real-time updates -->
|
||||
<script>
|
||||
// Connect to SSE streams
|
||||
const statsSource = new EventSource('/api/v3/stream/stats');
|
||||
const displaySource = new EventSource('/api/v3/stream/display');
|
||||
// Assign to window so reconnectSSE() in app.js can reach them.
|
||||
window.statsSource = new EventSource('/api/v3/stream/stats');
|
||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||
|
||||
statsSource.onmessage = function(event) {
|
||||
window.statsSource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateSystemStats(data);
|
||||
};
|
||||
|
||||
displaySource.onmessage = function(event) {
|
||||
window.displaySource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateDisplayPreview(data);
|
||||
};
|
||||
|
||||
// Connection status
|
||||
statsSource.addEventListener('open', function() {
|
||||
document.getElementById('connection-status').innerHTML = `
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span class="text-gray-600">Connected</span>
|
||||
`;
|
||||
});
|
||||
function _setConnectionStatus(connected, reconnecting) {
|
||||
const el = document.getElementById('connection-status');
|
||||
if (!el) return;
|
||||
if (connected) {
|
||||
el.innerHTML = `
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span class="text-gray-600">Connected</span>
|
||||
`;
|
||||
} else if (reconnecting) {
|
||||
el.innerHTML = `
|
||||
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span class="text-gray-600">Reconnecting…</span>
|
||||
`;
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span class="text-gray-600" title="Connection lost — try refreshing the page">Disconnected</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
statsSource.addEventListener('error', function() {
|
||||
document.getElementById('connection-status').innerHTML = `
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span class="text-gray-600">Disconnected</span>
|
||||
`;
|
||||
});
|
||||
var _statsErrorCount = 0;
|
||||
|
||||
// Named on window so reconnectSSE() in app.js can reattach them after
|
||||
// replacing the EventSource instances.
|
||||
window._statsOpenHandler = function() {
|
||||
_statsErrorCount = 0;
|
||||
_setConnectionStatus(true, false);
|
||||
};
|
||||
window._statsErrorHandler = function() {
|
||||
_statsErrorCount++;
|
||||
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
|
||||
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
|
||||
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
|
||||
};
|
||||
window._displayErrorHandler = function() {
|
||||
// Display stream errors don't change the status badge but log to console
|
||||
// so failures aren't completely silent.
|
||||
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
|
||||
};
|
||||
|
||||
window.statsSource.addEventListener('open', window._statsOpenHandler);
|
||||
window.statsSource.addEventListener('error', window._statsErrorHandler);
|
||||
window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||
|
||||
function updateSystemStats(data) {
|
||||
// Update CPU in header
|
||||
@@ -1807,23 +1860,53 @@
|
||||
},
|
||||
|
||||
loadTabContent(tab) {
|
||||
// Try to load content for the active tab
|
||||
const contentEl = document.getElementById(tab + '-content');
|
||||
// data-loaded: already fetched. data-loading: a fetch is queued or in
|
||||
// flight. Both guard against re-entry so a panel loads exactly once, even
|
||||
// if the tab is reopened before an in-progress (or polling) load settles.
|
||||
if (!contentEl || contentEl.hasAttribute('data-loaded') || contentEl.hasAttribute('data-loading')) return;
|
||||
const url = contentEl.getAttribute('hx-get');
|
||||
if (!url) return;
|
||||
|
||||
contentEl.setAttribute('data-loading', 'true');
|
||||
|
||||
// htmx.ajax issues the request and swaps the response into the panel
|
||||
// directly, so it works even before htmx has wired up the element's
|
||||
// hx-trigger listeners. data-loaded is stamped on success so the panel
|
||||
// loads once; the activeTab check drops loads for a tab the user navigated
|
||||
// away from while htmx was still loading (avoids fetching hidden panels).
|
||||
const swap = contentEl.getAttribute('hx-swap') || 'innerHTML';
|
||||
const load = () => {
|
||||
if (this.activeTab !== tab || contentEl.hasAttribute('data-loaded')) {
|
||||
contentEl.removeAttribute('data-loading');
|
||||
return;
|
||||
}
|
||||
return htmx.ajax('GET', url, { target: contentEl, swap: swap })
|
||||
.then(() => contentEl.setAttribute('data-loaded', 'true'))
|
||||
.catch(() => {}) // leave unstamped on failure so it can retry
|
||||
.finally(() => contentEl.removeAttribute('data-loading'));
|
||||
};
|
||||
|
||||
if (typeof htmx !== 'undefined') {
|
||||
const contentId = tab + '-content';
|
||||
const contentEl = document.getElementById(contentId);
|
||||
if (contentEl && !contentEl.hasAttribute('data-loaded')) {
|
||||
// Trigger HTMX load
|
||||
htmx.trigger(contentEl, 'revealed');
|
||||
}
|
||||
} else {
|
||||
// HTMX not available, use direct fetch
|
||||
console.warn('HTMX not available, using direct fetch for tab:', tab);
|
||||
if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
|
||||
loadOverviewDirect();
|
||||
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
|
||||
loadWifiDirect();
|
||||
}
|
||||
load();
|
||||
return;
|
||||
}
|
||||
|
||||
// htmx is loaded from a CDN and may not be ready yet. Poll until it is,
|
||||
// then load; if it never arrives, fall back to a direct fetch.
|
||||
let tries = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (typeof htmx !== 'undefined') {
|
||||
clearInterval(timer);
|
||||
load();
|
||||
} else if (++tries > 100) { // ~10s
|
||||
clearInterval(timer);
|
||||
contentEl.removeAttribute('data-loading');
|
||||
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
||||
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
||||
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
|
||||
async loadInstalledPlugins() {
|
||||
@@ -4566,6 +4649,9 @@
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
||||
|
||||
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
|
||||
|
||||
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "start_display"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||
<i class="fas fa-play mr-2"></i>
|
||||
Start Display
|
||||
@@ -82,7 +82,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "stop_display"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||
<i class="fas fa-stop mr-2"></i>
|
||||
Stop Display
|
||||
@@ -91,7 +91,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "git_pull"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Update Code
|
||||
@@ -101,7 +101,7 @@
|
||||
hx-vals='{"action": "reboot_system"}'
|
||||
hx-confirm="Are you sure you want to reboot the system?"
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
||||
<i class="fas fa-power-off mr-2"></i>
|
||||
Reboot System
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "start_display"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||
<i class="fas fa-play mr-2"></i>
|
||||
Start Display
|
||||
@@ -160,7 +160,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "stop_display"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||
<i class="fas fa-stop mr-2"></i>
|
||||
Stop Display
|
||||
@@ -170,7 +170,7 @@
|
||||
hx-vals='{"action": "git_pull"}'
|
||||
hx-confirm="This will stash any local changes and update the code. Continue?"
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Update Code
|
||||
@@ -180,7 +180,7 @@
|
||||
hx-vals='{"action": "reboot_system"}'
|
||||
hx-confirm="Are you sure you want to reboot the system?"
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
||||
<i class="fas fa-power-off mr-2"></i>
|
||||
Reboot System
|
||||
@@ -190,7 +190,7 @@
|
||||
hx-vals='{"action": "shutdown_system"}'
|
||||
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System shutting down...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
|
||||
<i class="fas fa-power-off mr-2"></i>
|
||||
Shutdown System
|
||||
@@ -199,7 +199,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "restart_display_service"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-redo mr-2"></i>
|
||||
Restart Display Service
|
||||
@@ -208,7 +208,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "restart_web_service"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Web service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-redo mr-2"></i>
|
||||
Restart Web Service
|
||||
|
||||
@@ -497,15 +497,31 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="array-table-container mt-1" data-field-id="{{ field_id }}" data-full-key="{{ full_key }}" data-max-items="{{ max_items }}" data-plugin-id="{{ plugin_id }}">
|
||||
<table class="min-w-full divide-y divide-gray-200 border border-gray-300 rounded-lg">
|
||||
<div style="overflow-x:auto">
|
||||
<table class="divide-y divide-gray-200 border border-gray-300 rounded-lg" style="min-width:max-content;width:100%">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
{% for col_name in display_columns %}
|
||||
{% set col_def = item_properties.get(col_name, {}) %}
|
||||
{% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %}
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ col_title }}</th>
|
||||
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
||||
{% set col_enum = col_def.get('enum', []) %}
|
||||
{% set _raw_ctype = col_def.get('type', 'string') %}
|
||||
{% if _raw_ctype is iterable and _raw_ctype is not string %}
|
||||
{% set col_ctype = (_raw_ctype | reject('equalto','null') | list | first) or 'string' %}
|
||||
{% else %}
|
||||
{% set col_ctype = _raw_ctype or 'string' %}
|
||||
{% endif %}
|
||||
{% if col_xwidget == 'date-picker' %}{% set col_min_w = '140px' %}
|
||||
{% elif col_xwidget == 'time-picker' %}{% set col_min_w = '115px' %}
|
||||
{% elif col_xwidget == 'file-upload-single' %}{% set col_min_w = '200px' %}
|
||||
{% elif col_enum %}{% set col_min_w = '90px' %}
|
||||
{% elif col_ctype == 'boolean' %}{% set col_min_w = '60px' %}
|
||||
{% elif col_ctype in ['integer', 'number'] %}{% set col_min_w = '80px' %}
|
||||
{% else %}{% set col_min_w = '110px' %}{% endif %}
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:{{ col_min_w }}">{{ col_title }}</th>
|
||||
{% endfor %}
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Actions</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:90px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="{{ field_id }}_tbody" class="bg-white divide-y divide-gray-200">
|
||||
@@ -514,9 +530,24 @@
|
||||
<tr class="array-table-row" data-index="{{ item_index }}">
|
||||
{% for col_name in display_columns %}
|
||||
{% set col_def = item_properties.get(col_name, {}) %}
|
||||
{% set col_type = col_def.get('type', 'string') %}
|
||||
{# Normalize nullable types e.g. ["null","integer"] → "integer" #}
|
||||
{% set _raw_type = col_def.get('type', 'string') %}
|
||||
{% if _raw_type is iterable and _raw_type is not string %}
|
||||
{% set col_type = (_raw_type | reject('equalto','null') | list | first) or 'string' %}
|
||||
{% else %}
|
||||
{% set col_type = _raw_type or 'string' %}
|
||||
{% endif %}
|
||||
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
||||
{% set col_enum = col_def.get('enum', []) %}
|
||||
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
|
||||
{% elif col_xwidget == 'time-picker' %}{% set td_min_w = '115px' %}
|
||||
{% elif col_xwidget == 'file-upload-single' %}{% set td_min_w = '200px' %}
|
||||
{% elif col_enum %}{% set td_min_w = '90px' %}
|
||||
{% elif col_type == 'boolean' %}{% set td_min_w = '60px' %}
|
||||
{% elif col_type in ['integer', 'number'] %}{% set td_min_w = '80px' %}
|
||||
{% else %}{% set td_min_w = '110px' %}{% endif %}
|
||||
<td class="px-3 py-3 whitespace-nowrap" style="min-width:{{ td_min_w }};vertical-align:middle">
|
||||
{% if col_type == 'boolean' %}
|
||||
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="false">
|
||||
<input type="checkbox"
|
||||
@@ -533,6 +564,43 @@
|
||||
{% if col_type == 'integer' %}step="1"{% else %}step="any"{% endif %}
|
||||
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
||||
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
|
||||
{% elif col_enum %}
|
||||
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
|
||||
{% for opt in col_enum %}{% if opt is not none %}
|
||||
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endif %}{% endfor %}
|
||||
</select>
|
||||
{% elif col_xwidget == 'date-picker' %}
|
||||
<input type="date"
|
||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||
value="{{ col_value if col_value is not none else '' }}"
|
||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
{% elif col_xwidget == 'time-picker' %}
|
||||
<input type="time"
|
||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||
value="{{ col_value if col_value is not none else '00:00' }}"
|
||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
{% elif col_xwidget == 'file-upload-single' %}
|
||||
{% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %}
|
||||
<div class="flex items-center gap-1">
|
||||
{% if col_value %}<img src="/{{ col_value }}" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
|
||||
<input type="text"
|
||||
id="{{ cell_input_id }}"
|
||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||
value="{{ col_value if col_value is not none else '' }}"
|
||||
class="block w-20 px-1 py-1 border border-gray-300 rounded text-xs"
|
||||
placeholder="path…">
|
||||
<label class="cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image">
|
||||
<i class="fas fa-upload"></i>
|
||||
<input type="file"
|
||||
accept="image/png,image/jpeg,image/bmp,image/gif"
|
||||
style="display:none"
|
||||
data-plugin-id="{{ plugin_id }}"
|
||||
data-target-input="{{ cell_input_id }}"
|
||||
onchange="(function(e){ const t=document.getElementById('{{ cell_input_id }}'); const p=t.previousElementSibling && t.previousElementSibling.tagName==='IMG' ? t.previousElementSibling : null; window.handleArrayTableImageUpload(e,t,p,'{{ plugin_id }}'); })(event)">
|
||||
</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<input type="text"
|
||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||
@@ -545,13 +613,60 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
|
||||
{# Actions cell: delete + optional edit button for advanced props #}
|
||||
{% set has_advanced = namespace(value=false) %}
|
||||
{% for k in item_properties.keys() %}{% if k not in display_columns and k != 'id' %}{% set has_advanced.value = true %}{% endif %}{% endfor %}
|
||||
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
|
||||
<button type="button"
|
||||
onclick="removeArrayTableRow(this)"
|
||||
class="text-red-600 hover:text-red-800 px-2 py-1">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% if has_advanced.value %}
|
||||
<button type="button"
|
||||
onclick="openArrayTableRowEditor(this)"
|
||||
class="text-blue-500 hover:text-blue-700 px-2 py-1 ml-1"
|
||||
title="Edit layout, style and other advanced properties">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
{# Hidden cell: flat hidden inputs for non-displayed props (layout, style, etc.) #}
|
||||
{% if has_advanced.value %}
|
||||
{% set adv_schema = namespace(d={}) %}
|
||||
{% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' %}{% set _ = adv_schema.d.update({k: v}) %}{% endif %}{% endfor %}
|
||||
<td style="display:none" class="array-table-advanced-data"
|
||||
data-prop-schema='{{ adv_schema.d|tojson }}'>
|
||||
{% for prop_name, prop_schema in adv_schema.d.items() %}
|
||||
{% set prop_type = prop_schema.get('type', 'string') %}
|
||||
{% if prop_type == 'object' and prop_schema.get('properties') %}
|
||||
{% for sub_name, sub_schema in prop_schema.get('properties', {}).items() %}
|
||||
{% set sub_val = item.get(prop_name, {}).get(sub_name) %}
|
||||
{% set sub_default = sub_schema.get('default') %}
|
||||
{% set final_val = sub_val if sub_val is not none else sub_default %}
|
||||
<input type="hidden"
|
||||
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}.{{ sub_name }}"
|
||||
data-nested-prop="{{ prop_name }}.{{ sub_name }}"
|
||||
data-prop-type="{{ sub_schema.get('type', 'string') }}"
|
||||
data-prop-schema='{{ sub_schema|tojson }}'
|
||||
value="{{ final_val if final_val is not none else '' }}">
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% set prop_val = item.get(prop_name) %}
|
||||
{% set prop_default = prop_schema.get('default') %}
|
||||
{% set final_val = prop_val if prop_val is not none else prop_default %}
|
||||
<input type="hidden"
|
||||
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}"
|
||||
data-nested-prop="{{ prop_name }}"
|
||||
data-prop-type="{{ prop_schema.get('type', 'string') }}"
|
||||
data-prop-schema='{{ prop_schema|tojson }}'
|
||||
value="{{ final_val if final_val is not none else '' }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -563,11 +678,58 @@
|
||||
data-max-items="{{ max_items }}"
|
||||
data-plugin-id="{{ plugin_id }}"
|
||||
data-item-properties='{% set ns = namespace(d={}) %}{% for k in display_columns %}{% if k in item_properties %}{% set _ = ns.d.update({k: item_properties[k]}) %}{% endif %}{% endfor %}{{ ns.d|tojson }}'
|
||||
data-full-item-properties='{{ item_properties|tojson }}'
|
||||
data-display-columns='{{ display_columns|tojson }}'
|
||||
class="mt-3 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md"
|
||||
{% if array_value|length >= max_items %}disabled style="opacity: 0.5;"{% endif %}>
|
||||
<i class="fas fa-plus mr-1"></i> Add Item
|
||||
</button>
|
||||
</div>{# end overflow-x:auto wrapper #}
|
||||
</div>
|
||||
{% elif x_widget == 'color-picker' %}
|
||||
{# RGB color array: R / G / B number inputs + visual swatch + sync'd hex picker #}
|
||||
{% set color_arr = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else [255, 255, 255]) %}
|
||||
{% set r_val = color_arr[0] if color_arr|length > 0 else 255 %}
|
||||
{% set g_val = color_arr[1] if color_arr|length > 1 else 255 %}
|
||||
{% set b_val = color_arr[2] if color_arr|length > 2 else 255 %}
|
||||
{% set hex_val = '#%02x%02x%02x' % (r_val|int, g_val|int, b_val|int) %}
|
||||
<div class="flex items-center gap-3 flex-wrap mt-1" id="{{ field_id }}_color_row">
|
||||
<input type="color"
|
||||
id="{{ field_id }}_hex"
|
||||
value="{{ hex_val }}"
|
||||
class="h-9 w-12 cursor-pointer rounded border border-gray-300"
|
||||
title="Color picker"
|
||||
oninput="(function(h){var r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);document.getElementById('{{ field_id }}_r').value=r;document.getElementById('{{ field_id }}_g').value=g;document.getElementById('{{ field_id }}_b').value=b;})(this.value)">
|
||||
<div class="flex items-center gap-1">
|
||||
<label class="text-xs text-gray-500 font-medium">R</label>
|
||||
<input type="number" min="0" max="255" step="1"
|
||||
id="{{ field_id }}_r"
|
||||
name="{{ full_key }}.0"
|
||||
value="{{ r_val }}"
|
||||
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
||||
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<label class="text-xs text-gray-500 font-medium">G</label>
|
||||
<input type="number" min="0" max="255" step="1"
|
||||
id="{{ field_id }}_g"
|
||||
name="{{ full_key }}.1"
|
||||
value="{{ g_val }}"
|
||||
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
||||
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<label class="text-xs text-gray-500 font-medium">B</label>
|
||||
<input type="number" min="0" max="255" step="1"
|
||||
id="{{ field_id }}_b"
|
||||
name="{{ full_key }}.2"
|
||||
value="{{ b_val }}"
|
||||
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
||||
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
|
||||
</div>
|
||||
<div class="w-8 h-8 rounded border border-gray-300 flex-shrink-0"
|
||||
style="background-color: rgb({{ r_val }}, {{ g_val }}, {{ b_val }})"
|
||||
title="Color preview"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Generic array-of-objects would go here if needed in the future #}
|
||||
@@ -626,7 +788,19 @@
|
||||
name="{{ full_key }}"
|
||||
value="{{ str_value }}">
|
||||
</div>
|
||||
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
|
||||
{% elif str_widget == 'json-file-manager' %}
|
||||
{# Embedded file manager — plugin's web_ui/file_manager.html served via /v3/plugin-ui/ route #}
|
||||
<div class="mt-1 rounded-lg border border-gray-200 overflow-hidden">
|
||||
<iframe id="{{ field_id }}_frame"
|
||||
src="/v3/plugin-ui/{{ plugin_id }}/web-ui/file_manager.html"
|
||||
style="width:100%;height:640px;border:none;"
|
||||
title="File Manager for {{ plugin_id }}"></iframe>
|
||||
</div>
|
||||
<p class="text-xs text-amber-600 mt-2 flex items-center">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Changes in the file manager save immediately — no need to click Save Configuration.
|
||||
</p>
|
||||
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager'] %}
|
||||
{# Render widget container #}
|
||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||
<script>
|
||||
@@ -643,7 +817,9 @@
|
||||
'enum': {{ (prop.enum or [])|tojson|safe }},
|
||||
'minimum': {{ prop.minimum|tojson if prop.minimum is defined else 'null' }},
|
||||
'maximum': {{ prop.maximum|tojson if prop.maximum is defined else 'null' }},
|
||||
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }}
|
||||
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
|
||||
'x-upload-config': {{ (prop.get('x-upload-config') or prop.get('x_upload_config') or {})|tojson|safe }},
|
||||
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
|
||||
};
|
||||
widget.render(container, config, value, { fieldId: '{{ field_id }}', name: '{{ full_key }}', pluginId: '{{ plugin_id }}' });
|
||||
}
|
||||
@@ -864,15 +1040,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Web UI Actions (if any) #}
|
||||
{% if web_ui_actions %}
|
||||
{# Web UI Actions — hide if schema has a dedicated file-manager widget,
|
||||
or if every action is marked ui_hidden in the manifest. #}
|
||||
{% set has_file_manager_widget = namespace(value=false) %}
|
||||
{% for _fk, _fp in schema.get('properties', {}).items() %}
|
||||
{% if (_fp.get('x-widget') or _fp.get('x_widget')) in ('json-file-manager', 'plugin-file-manager') %}
|
||||
{% set has_file_manager_widget.value = true %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% set visible_actions = [] %}
|
||||
{% for _a in web_ui_actions %}
|
||||
{% if not _a.get('ui_hidden', false) %}
|
||||
{% set _ = visible_actions.append(_a) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if visible_actions and not has_file_manager_widget.value %}
|
||||
<div class="mt-6 pt-4 border-t border-gray-200">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
|
||||
{% if web_ui_actions[0].section_description %}
|
||||
<p class="text-sm text-gray-600 mb-4">{{ web_ui_actions[0].section_description }}</p>
|
||||
{% if visible_actions[0].section_description %}
|
||||
<p class="text-sm text-gray-600 mb-4">{{ visible_actions[0].section_description }}</p>
|
||||
{% endif %}
|
||||
<div class="space-y-3">
|
||||
{% for action in web_ui_actions %}
|
||||
{% for action in visible_actions %}
|
||||
{% set action_id = "action-" ~ action.id ~ "-" ~ loop.index0 %}
|
||||
{% set status_id = "action-status-" ~ action.id ~ "-" ~ loop.index0 %}
|
||||
{% set bg_color = action.color or 'blue' %}
|
||||
|
||||
Reference in New Issue
Block a user