mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Merge remote-tracking branch 'origin/main' into check-412-conflict
# Conflicts: # src/image_utils.py
This commit is contained in:
+61
-26
@@ -37,10 +37,11 @@ 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, load_harness_spec,
|
||||
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
|
||||
)
|
||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||
check_scale_up,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||
@@ -96,12 +97,11 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
# matrix path does; explicit CLI flags still override the file.
|
||||
spec = load_harness_spec(plugin_dir)
|
||||
|
||||
# config_schema defaults (real-install behavior), then harness.json config,
|
||||
# then CLI --config — most specific wins.
|
||||
full_config = {"enabled": True}
|
||||
full_config.update(load_config_defaults(plugin_dir))
|
||||
full_config.update(spec.get("config", {}))
|
||||
full_config.update(config)
|
||||
# config_schema defaults (real-install behavior, with enabled forced True
|
||||
# so a plugin's own enabled:false default can't accidentally disable
|
||||
# testing), then harness.json config, then CLI --config — most specific
|
||||
# wins.
|
||||
full_config = build_full_config(plugin_dir, spec, config)
|
||||
|
||||
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
|
||||
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
|
||||
@@ -110,28 +110,55 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
effective_freeze = freeze_time or spec.get("freeze_time")
|
||||
effective_run_update = run_update and not spec.get("skip_update", False)
|
||||
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
|
||||
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||
)
|
||||
# The plugin's declared design size drives the scale-up fill check
|
||||
# (panels >= 2x the design size must not be left mostly empty).
|
||||
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||
fill_strict = spec.get("fill_check") == "strict"
|
||||
|
||||
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)
|
||||
# Every run: the base config, plus one per harness.json "variant" —
|
||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||
# tested alongside the classic default).
|
||||
runs = [(None, {}, golden_dir_override or (plugin_dir / 'test' / 'golden'))]
|
||||
for variant in spec.get("variants", []):
|
||||
name = variant.get("name") or "variant"
|
||||
vdir = plugin_dir / variant.get("golden_dir", f"test/golden-{name}")
|
||||
runs.append((name, variant.get("config", {}), vdir))
|
||||
|
||||
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")
|
||||
all_run_results: List[RenderResult] = []
|
||||
for variant_name, overlay, golden_dir in runs:
|
||||
run_config = {**full_config, **overlay}
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=run_config,
|
||||
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||
)
|
||||
|
||||
return results
|
||||
if update_golden:
|
||||
written = write_goldens(results, golden_dir)
|
||||
logger.info("Wrote %d golden image(s) for %s%s to %s", written, plugin_id,
|
||||
f" [{variant_name}]" if variant_name else "", golden_dir)
|
||||
else:
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
||||
|
||||
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||
if variant_name:
|
||||
for r in results:
|
||||
r.mode = f"{r.mode}@{variant_name}"
|
||||
|
||||
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")
|
||||
|
||||
all_run_results.extend(results)
|
||||
|
||||
return all_run_results
|
||||
|
||||
|
||||
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
@@ -147,6 +174,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
detail = " (golden ✓)"
|
||||
if r.update_error is not None:
|
||||
detail += f" (update warn: {r.update_error})"
|
||||
if r.fill_checked and r.fill_ok is None and r.fill_extent:
|
||||
# warn-only underfill: big panel left mostly empty
|
||||
ex, ey = r.fill_extent
|
||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
@@ -156,6 +187,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
elif r.golden_ok is False:
|
||||
status = "FAIL"
|
||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||
elif r.fill_ok is False:
|
||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||
status = "FAIL"
|
||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
|
||||
+198
-72
@@ -16,6 +16,7 @@ Opens at http://localhost:5001
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import argparse
|
||||
import logging
|
||||
@@ -44,6 +45,10 @@ MAX_HEIGHT = 512
|
||||
MIN_WIDTH = 1
|
||||
MIN_HEIGHT = 1
|
||||
|
||||
# plugin_id arrives in request input and is used to build filesystem paths —
|
||||
# allowlist it (same pattern the web UI's pages_v3 uses)
|
||||
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Plugin discovery
|
||||
@@ -106,15 +111,30 @@ def discover_plugins() -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||
"""Find a plugin directory by ID."""
|
||||
"""Find a plugin directory by ID.
|
||||
|
||||
plugin_id comes from request input: it must pass an allowlist match,
|
||||
and the resulting directory is normalized and required to live inside
|
||||
one of the plugin search dirs, so a crafted id can never name a path
|
||||
outside them.
|
||||
"""
|
||||
if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
return None
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in get_search_dirs():
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_dir)
|
||||
if result:
|
||||
return Path(result)
|
||||
if not result:
|
||||
continue
|
||||
# Normalize WITHOUT following symlinks (dev plugins are often
|
||||
# symlinked into plugins/) and require lexical containment in the
|
||||
# search dir, so no id can ever name a path outside it.
|
||||
result_abs = os.path.abspath(str(result))
|
||||
root_abs = os.path.abspath(str(search_dir))
|
||||
if os.path.commonpath([result_abs, root_abs]) == root_abs:
|
||||
return Path(result_abs)
|
||||
return None
|
||||
|
||||
|
||||
@@ -176,6 +196,118 @@ def api_plugin_defaults(plugin_id):
|
||||
return jsonify({'defaults': defaults})
|
||||
|
||||
|
||||
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
|
||||
skip_update):
|
||||
"""Render one plugin at one size. Returns the /api/render response dict.
|
||||
|
||||
A fresh plugin instance per call, mirroring the safety harness, so sizes
|
||||
never share state.
|
||||
"""
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
loader = PluginLoader()
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
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=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run update()
|
||||
if not skip_update:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
except Exception as e:
|
||||
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
|
||||
warnings.append(f"update() raised: {type(e).__name__} — see server log")
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
except Exception as e:
|
||||
logger.warning("display() raised for plugin %s", plugin_id, exc_info=True)
|
||||
errors.append(f"display() raised: {type(e).__name__} — see server log")
|
||||
|
||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||
|
||||
return {
|
||||
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'render_time_ms': render_time_ms,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
}
|
||||
|
||||
|
||||
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
||||
"""Re-derive a plugin directory from the search dirs' own listings.
|
||||
|
||||
Path-injection barrier: unlike ``Path.iterdir()`` (which CodeQL doesn't
|
||||
recognize as a taint-clearing enumeration), ``os.scandir()`` is. The
|
||||
returned Path is built from a trusted root plus a name the filesystem
|
||||
itself produced under that root via scandir — request-derived strings
|
||||
never enter its construction — so a crafted plugin id can never make
|
||||
downstream file access leave the plugin search dirs. Comparison is by
|
||||
name, deliberately without symlink resolution (dev plugins are
|
||||
commonly symlinked into plugins/).
|
||||
"""
|
||||
wanted_name = Path(os.path.normpath(str(plugin_dir))).name
|
||||
for search_dir in get_search_dirs():
|
||||
search_dir_str = str(search_dir)
|
||||
try:
|
||||
with os.scandir(search_dir_str) as entries:
|
||||
for entry in entries:
|
||||
if entry.name == wanted_name and entry.is_dir():
|
||||
return Path(search_dir_str) / entry.name
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_render_request(data):
|
||||
"""Shared /api/render* request prep. Returns (plugin_dir, manifest, config,
|
||||
mock_data, skip_update) or raises ValueError with a client message."""
|
||||
plugin_id = data['plugin_id']
|
||||
candidate_dir = find_plugin_dir(plugin_id)
|
||||
# Never reuse `candidate_dir` past this point: it's built from
|
||||
# request-derived input, and a variable reassigned only on some paths
|
||||
# isn't a barrier CodeQL's flow analysis honors. `trusted_dir` is the
|
||||
# sole name used below, always the scandir-sourced result.
|
||||
trusted_dir = _trusted_plugin_dir(candidate_dir) if candidate_dir else None
|
||||
if not trusted_dir:
|
||||
raise LookupError(f'Plugin not found: {plugin_id}')
|
||||
|
||||
manifest_path = trusted_dir / 'manifest.json'
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config = {'enabled': True}
|
||||
config.update(load_config_defaults(trusted_dir))
|
||||
config.update(data.get('config', {}))
|
||||
|
||||
return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
|
||||
|
||||
|
||||
@app.route('/api/render', methods=['POST'])
|
||||
def api_render():
|
||||
"""Render a plugin and return the display as base64 PNG."""
|
||||
@@ -183,11 +315,6 @@ def api_render():
|
||||
if not data or 'plugin_id' not in data:
|
||||
return jsonify({'error': 'plugin_id is required'}), 400
|
||||
|
||||
plugin_id = data['plugin_id']
|
||||
user_config = data.get('config', {})
|
||||
mock_data = data.get('mock_data', {})
|
||||
skip_update = data.get('skip_update', False)
|
||||
|
||||
try:
|
||||
width = int(data.get('width', 128))
|
||||
height = int(data.get('height', 32))
|
||||
@@ -199,78 +326,77 @@ def api_render():
|
||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||
|
||||
# Find plugin
|
||||
plugin_dir = find_plugin_dir(plugin_id)
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
# Load manifest
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config_defaults = load_config_defaults(plugin_dir)
|
||||
config = {'enabled': True}
|
||||
config.update(config_defaults)
|
||||
config.update(user_config)
|
||||
|
||||
# Create display manager and mocks
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
# Load plugin
|
||||
loader = PluginLoader()
|
||||
errors = []
|
||||
warnings = []
|
||||
try:
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
||||
except Exception:
|
||||
# Bad manifest.json / schema / fixture — details go to the dev's
|
||||
# console, not the HTTP response
|
||||
app.logger.exception('render request preparation failed')
|
||||
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
||||
|
||||
try:
|
||||
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=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Failed to load plugin: {e}'}), 500
|
||||
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
||||
mock_data, width, height, skip_update)
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during render')
|
||||
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
||||
return jsonify(result)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run update()
|
||||
if not skip_update:
|
||||
@app.route('/api/sizes')
|
||||
def api_sizes():
|
||||
"""The representative panel-size sample the safety harness renders at."""
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
return jsonify({'sizes': [list(s) for s in DEFAULT_TEST_SIZES]})
|
||||
|
||||
|
||||
MAX_MATRIX_SIZES = 12
|
||||
|
||||
|
||||
@app.route('/api/render-matrix', methods=['POST'])
|
||||
def api_render_matrix():
|
||||
"""Render a plugin at a list of sizes (default: the harness sample) so the
|
||||
UI can show a side-by-side multi-resolution gallery."""
|
||||
data = request.get_json()
|
||||
if not data or 'plugin_id' not in data:
|
||||
return jsonify({'error': 'plugin_id is required'}), 400
|
||||
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
sizes = data.get('sizes') or [list(s) for s in DEFAULT_TEST_SIZES]
|
||||
if len(sizes) > MAX_MATRIX_SIZES:
|
||||
return jsonify({'error': f'at most {MAX_MATRIX_SIZES} sizes per request'}), 400
|
||||
parsed_sizes = []
|
||||
for pair in sizes:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
except Exception as e:
|
||||
warnings.append(f"update() raised: {e}")
|
||||
w, h = int(pair[0]), int(pair[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return jsonify({'error': f'invalid size entry {pair!r} (expected [w, h])'}), 400
|
||||
if not (MIN_WIDTH <= w <= MAX_WIDTH and MIN_HEIGHT <= h <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'size {w}x{h} out of bounds'}), 400
|
||||
parsed_sizes.append((w, h))
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
except Exception as e:
|
||||
errors.append(f"display() raised: {e}")
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
||||
except Exception:
|
||||
app.logger.exception('render request preparation failed')
|
||||
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
||||
|
||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||
|
||||
return jsonify({
|
||||
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'render_time_ms': render_time_ms,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
})
|
||||
results = []
|
||||
for w, h in parsed_sizes:
|
||||
try:
|
||||
results.append(_render_once(data['plugin_id'], plugin_dir, manifest,
|
||||
config, mock_data, w, h, skip_update))
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during %dx%d render', w, h)
|
||||
results.append({'image': None, 'width': w, 'height': h,
|
||||
'render_time_ms': 0,
|
||||
'errors': ['Failed to load plugin; see server log'],
|
||||
'warnings': []})
|
||||
return jsonify({'results': results})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -28,7 +28,7 @@ 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,
|
||||
build_full_config, find_plugin_dir, load_manifest,
|
||||
)
|
||||
logger = get_logger("[Render Plugin]")
|
||||
|
||||
@@ -83,16 +83,13 @@ def main() -> int:
|
||||
manifest = load_manifest(Path(plugin_dir))
|
||||
|
||||
# Parse config: start with schema defaults, then apply overrides
|
||||
config_defaults = load_config_defaults(Path(plugin_dir))
|
||||
try:
|
||||
user_config = json.loads(args.config)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Invalid JSON config: %s", e)
|
||||
return 1
|
||||
|
||||
config = {'enabled': True}
|
||||
config.update(config_defaults)
|
||||
config.update(user_config)
|
||||
config = build_full_config(Path(plugin_dir), cli_config=user_config)
|
||||
|
||||
# Load mock data if provided
|
||||
mock_data = {}
|
||||
|
||||
@@ -209,6 +209,11 @@
|
||||
onchange="onConfigChange()">
|
||||
<span class="text-xs ml-2" style="color: var(--text-secondary);">px</span>
|
||||
</div>
|
||||
<select id="sizePreset" onchange="applySizePreset()"
|
||||
class="w-full mt-2 px-2 py-1.5 rounded text-xs"
|
||||
style="background: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
||||
<option value="">Preset sizes…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Config form -->
|
||||
@@ -242,13 +247,18 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Render button -->
|
||||
<!-- Render buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
class="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style="background: var(--accent);">
|
||||
Render
|
||||
</button>
|
||||
<button onclick="renderAllSizes()" id="renderAllBtn" title="Render at every harness test size"
|
||||
class="px-4 py-2.5 rounded-lg text-sm font-medium"
|
||||
style="background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color);">
|
||||
All Sizes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -311,6 +321,15 @@
|
||||
<div id="messagesPanel" class="panel p-3 hidden">
|
||||
<div id="messagesList" class="text-xs font-mono space-y-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-size gallery -->
|
||||
<div id="galleryPanel" class="panel p-4 hidden">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs font-medium" style="color: var(--text-secondary);">All Sizes</span>
|
||||
<span class="text-xs" style="color: var(--text-secondary);" id="galleryStatus"></span>
|
||||
</div>
|
||||
<div id="galleryGrid" class="flex flex-wrap gap-4 items-start"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -340,8 +359,30 @@
|
||||
opt.textContent = `${p.name} (${p.id})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
// Load harness size presets
|
||||
try {
|
||||
const sizesRes = await fetch('/api/sizes');
|
||||
const sizesData = await sizesRes.json();
|
||||
const preset = document.getElementById('sizePreset');
|
||||
(sizesData.sizes || []).forEach(([w, h]) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `${w}x${h}`;
|
||||
opt.textContent = `${w} x ${h}`;
|
||||
preset.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* presets are a convenience; ignore */ }
|
||||
});
|
||||
|
||||
function applySizePreset() {
|
||||
const value = document.getElementById('sizePreset').value;
|
||||
if (!value) return;
|
||||
const [w, h] = value.split('x');
|
||||
document.getElementById('displayWidth').value = w;
|
||||
document.getElementById('displayHeight').value = h;
|
||||
onConfigChange();
|
||||
}
|
||||
|
||||
// ---------- Plugin selection ----------
|
||||
async function onPluginChange() {
|
||||
const pluginId = document.getElementById('pluginSelect').value;
|
||||
@@ -485,6 +526,89 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Multi-size gallery ----------
|
||||
async function renderAllSizes() {
|
||||
if (!currentPluginId) return;
|
||||
|
||||
const btn = document.getElementById('renderAllBtn');
|
||||
const panel = document.getElementById('galleryPanel');
|
||||
const grid = document.getElementById('galleryGrid');
|
||||
const status = document.getElementById('galleryStatus');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Rendering…';
|
||||
panel.classList.remove('hidden');
|
||||
grid.innerHTML = '';
|
||||
status.textContent = 'Rendering at all harness sizes…';
|
||||
|
||||
const config = jsonEditor ? jsonEditor.getValue() : {};
|
||||
config.enabled = true;
|
||||
let mockData = {};
|
||||
const mockInput = document.getElementById('mockDataInput').value.trim();
|
||||
if (mockInput) {
|
||||
try { mockData = JSON.parse(mockInput); }
|
||||
catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/render-matrix', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plugin_id: currentPluginId,
|
||||
config: config,
|
||||
mock_data: mockData,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
status.textContent = data.error;
|
||||
return;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
(data.results || []).forEach(r => {
|
||||
const cell = document.createElement('div');
|
||||
cell.style.cssText = 'display:flex;flex-direction:column;gap:4px;';
|
||||
const failed = (r.errors || []).length > 0 || !r.image;
|
||||
if (failed) failures++;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'text-xs font-mono';
|
||||
label.style.color = failed ? '#f87171' : 'var(--text-secondary)';
|
||||
label.textContent = `${r.width}x${r.height} · ${r.render_time_ms}ms`;
|
||||
cell.appendChild(label);
|
||||
|
||||
if (r.image) {
|
||||
const img = document.createElement('img');
|
||||
img.src = r.image;
|
||||
// Small panels get 2x zoom so they stay legible in the grid
|
||||
const zoom = r.height >= 128 ? 1 : 2;
|
||||
img.style.cssText =
|
||||
`image-rendering: pixelated; width:${r.width * zoom}px; ` +
|
||||
`height:${r.height * zoom}px; ` +
|
||||
`border:1px solid ${failed ? '#f87171' : 'var(--border-color)'};`;
|
||||
cell.appendChild(img);
|
||||
}
|
||||
if (failed) {
|
||||
const err = document.createElement('span');
|
||||
err.className = 'text-xs font-mono';
|
||||
err.style.color = '#f87171';
|
||||
err.textContent = (r.errors || ['render failed']).join('; ');
|
||||
cell.appendChild(err);
|
||||
}
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
status.textContent = failures
|
||||
? `${failures} size(s) failed`
|
||||
: `${(data.results || []).length} sizes rendered`;
|
||||
} catch (e) {
|
||||
status.textContent = `Network error: ${e.message}`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'All Sizes';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Zoom ----------
|
||||
function updateZoom() {
|
||||
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
||||
|
||||
@@ -78,21 +78,17 @@ class WiFiMonitorDaemon:
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Get current status before checking
|
||||
status = self.wifi_manager.get_wifi_status()
|
||||
ethernet_connected = self.wifi_manager._is_ethernet_connected()
|
||||
|
||||
# Check WiFi status and manage AP mode
|
||||
state_changed = self.wifi_manager.check_and_manage_ap_mode()
|
||||
|
||||
# Get updated status after check
|
||||
updated_status = self.wifi_manager.get_wifi_status()
|
||||
updated_ethernet = self.wifi_manager._is_ethernet_connected()
|
||||
|
||||
# One combined check that also returns the state it observed —
|
||||
# the previous flow fetched status before AND after the check
|
||||
# on top of the check's own internal fetch, each one several
|
||||
# nmcli subprocess forks, every 30s, forever.
|
||||
(state_changed, updated_status, updated_ethernet,
|
||||
ap_active) = self.wifi_manager.check_and_manage_ap_mode_with_state()
|
||||
|
||||
current_state = {
|
||||
'connected': updated_status.connected,
|
||||
'ethernet_connected': updated_ethernet,
|
||||
'ap_active': updated_status.ap_mode_active,
|
||||
'ap_active': ap_active,
|
||||
'ssid': updated_status.ssid
|
||||
}
|
||||
|
||||
@@ -109,7 +105,7 @@ class WiFiMonitorDaemon:
|
||||
else:
|
||||
logger.debug("Ethernet not connected")
|
||||
|
||||
if updated_status.ap_mode_active:
|
||||
if ap_active:
|
||||
logger.info(f"AP mode ACTIVE - SSID: {ap_ssid} (IP: 192.168.4.1)")
|
||||
else:
|
||||
logger.debug("AP mode inactive")
|
||||
@@ -123,16 +119,16 @@ class WiFiMonitorDaemon:
|
||||
# Log periodic status (less verbose)
|
||||
if updated_status.connected:
|
||||
logger.debug(f"Status check: WiFi={updated_status.ssid} ({updated_status.signal}%), "
|
||||
f"Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||
f"Ethernet={updated_ethernet}, AP={ap_active}")
|
||||
else:
|
||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={ap_active}")
|
||||
|
||||
# Escalating recovery: if nmcli reports connected but actual internet
|
||||
# is unreachable for several consecutive checks, restart NetworkManager.
|
||||
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
||||
# AP-enable trigger clean and avoid false-positive AP enables from
|
||||
# transient packet loss on otherwise working WiFi.
|
||||
if updated_status.connected and not updated_status.ap_mode_active:
|
||||
if updated_status.connected and not ap_active:
|
||||
if not self.wifi_manager.check_internet_connectivity():
|
||||
self._consecutive_internet_failures += 1
|
||||
logger.warning(
|
||||
|
||||
Reference in New Issue
Block a user