mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-20 11:38:37 +00:00
Compare commits
7 Commits
d297dd6217
...
feat/tools
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2524e918d | ||
|
|
eb6687ceca | ||
|
|
60b64144a5 | ||
|
|
5a1a095e16 | ||
|
|
6aec2d9b78 | ||
|
|
6c9a3510ee | ||
|
|
6ea9862c14 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,7 +8,6 @@ config/config_secrets.json
|
||||
config/config.json
|
||||
config/config.json.backup
|
||||
config/wifi_config.json
|
||||
config/uninstalled_plugins.json
|
||||
credentials.json
|
||||
token.pickle
|
||||
|
||||
|
||||
@@ -75,20 +75,12 @@ def install_via_pip(package_name):
|
||||
Debian/Ubuntu-based systems without a virtual environment.
|
||||
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
||||
exhausting /tmp space during compilation.
|
||||
--ignore-installed stops pip from trying to *uninstall* packages that were
|
||||
installed by apt (e.g. python3-requests). Those Debian packages ship no
|
||||
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
|
||||
and aborts the whole install. With --ignore-installed, pip lays the new
|
||||
version down in /usr/local where it shadows the apt copy instead of removing
|
||||
it. This matters when a pip dependency (google-api-python-client pulls a
|
||||
newer requests) needs to upgrade an apt-managed package.
|
||||
|
||||
Returns (success, output).
|
||||
"""
|
||||
print(f"Installing {package_name} via pip...")
|
||||
success, output = _run([
|
||||
sys.executable, '-m', 'pip', 'install',
|
||||
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
|
||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
||||
])
|
||||
if success:
|
||||
print(f"Successfully installed {package_name} via pip")
|
||||
@@ -208,7 +200,7 @@ def main():
|
||||
if setup_py.exists():
|
||||
# Try installing - use regular install, not editable mode
|
||||
# This is optional for web interface and should already be installed in Step 6
|
||||
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--ignore-installed', str(rgbmatrix_path)])
|
||||
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
|
||||
if ok:
|
||||
print("rgbmatrix module installed successfully")
|
||||
else:
|
||||
|
||||
@@ -1505,68 +1505,30 @@ class DisplayController:
|
||||
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||
self._live_resume_index = None
|
||||
|
||||
def _collect_live_modes(self):
|
||||
"""Return every currently live-priority mode, in registration order.
|
||||
|
||||
Scans all registered plugin modes; for each plugin that has live
|
||||
priority *and* live content, collects the specific live mode(s) it
|
||||
reports via get_live_modes() (only those actually registered), falling
|
||||
back to the scanned mode name when it ends in '_live'. Deduplicated,
|
||||
preserving order. A plugin registered under several mode keys (the
|
||||
sports plugins register one per league) contributes each live mode once.
|
||||
def _check_live_priority(self):
|
||||
"""
|
||||
Check all plugins for live priority content.
|
||||
Returns the mode that should be displayed if live content is found, None otherwise.
|
||||
"""
|
||||
live = []
|
||||
seen = set()
|
||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||
and hasattr(plugin_instance, 'has_live_content')):
|
||||
continue
|
||||
try:
|
||||
if not (plugin_instance.has_live_priority()
|
||||
and plugin_instance.has_live_content()):
|
||||
continue
|
||||
resolved = []
|
||||
if hasattr(plugin_instance, 'get_live_modes'):
|
||||
for suggested_mode in (plugin_instance.get_live_modes() or []):
|
||||
if suggested_mode in self.plugin_modes:
|
||||
resolved.append(suggested_mode)
|
||||
if not resolved and mode_name.endswith('_live'):
|
||||
resolved.append(mode_name)
|
||||
for m in resolved:
|
||||
if m not in seen:
|
||||
seen.add(m)
|
||||
live.append(m)
|
||||
except Exception as e:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return live
|
||||
|
||||
def _check_live_priority(self, advance=False):
|
||||
"""Return the live-priority mode to display, or None if nothing is live.
|
||||
|
||||
When several plugins report live content at once (e.g. a baseball game
|
||||
and a soccer match), this round-robins between them so the display
|
||||
alternates each dwell instead of pinning to whichever plugin is first in
|
||||
registration order.
|
||||
|
||||
advance=False (default): a non-advancing peek — returns the live mode
|
||||
already on screen if it is still live, otherwise the first live mode.
|
||||
Used by the Vegas coordinator and the vegas-active check, which only
|
||||
need to know whether *any* game is live (and must not spin the cursor).
|
||||
|
||||
advance=True: the rotation pick — returns the live mode *after* the one
|
||||
currently shown, so each dwell advances to the next live game. The
|
||||
currently-displayed mode is the cursor, so this stays correct as games
|
||||
start and end (no separate index to keep in sync).
|
||||
"""
|
||||
live_modes = self._collect_live_modes()
|
||||
if not live_modes:
|
||||
return None
|
||||
if self.current_display_mode in live_modes:
|
||||
if advance:
|
||||
idx = live_modes.index(self.current_display_mode)
|
||||
return live_modes[(idx + 1) % len(live_modes)]
|
||||
return self.current_display_mode
|
||||
return live_modes[0]
|
||||
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'):
|
||||
try:
|
||||
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
|
||||
# Get the specific live mode from the plugin if available
|
||||
if hasattr(plugin_instance, 'get_live_modes'):
|
||||
live_modes = plugin_instance.get_live_modes()
|
||||
if live_modes and len(live_modes) > 0:
|
||||
# Verify the mode actually exists before returning it
|
||||
for suggested_mode in live_modes:
|
||||
if suggested_mode in self.plugin_modes:
|
||||
return suggested_mode
|
||||
# If suggested modes don't exist, fall through to check current mode
|
||||
# Fallback: if this mode ends with _live, return it
|
||||
if mode_name.endswith('_live'):
|
||||
return mode_name
|
||||
except Exception as e:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
"""Run the display controller, switching between displays."""
|
||||
@@ -1727,11 +1689,9 @@ class DisplayController:
|
||||
# Display failed, clear the status and continue normally
|
||||
wifi_status_data = None
|
||||
|
||||
# Check for live priority content and switch to it immediately.
|
||||
# advance=True so multiple simultaneously-live games take turns
|
||||
# (round-robin) instead of pinning to the first plugin.
|
||||
# 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(advance=True)
|
||||
live_priority_mode = self._check_live_priority()
|
||||
self._apply_live_priority(live_priority_mode)
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
|
||||
@@ -322,19 +322,10 @@ class StateReconciliation:
|
||||
and hasattr(self.store_manager, 'was_recently_uninstalled')
|
||||
and self.store_manager.was_recently_uninstalled(plugin_id)
|
||||
)
|
||||
# Also refuse to resurrect a plugin the user has persistently
|
||||
# uninstalled. Unlike the in-memory race guard above, this record
|
||||
# survives restarts, so the user's removal sticks across updates.
|
||||
persistently_uninstalled = (
|
||||
self.store_manager is not None
|
||||
and hasattr(self.store_manager, 'is_plugin_uninstalled')
|
||||
and self.store_manager.is_plugin_uninstalled(plugin_id)
|
||||
)
|
||||
can_repair = (
|
||||
self.store_manager is not None
|
||||
and not previously_unrecoverable
|
||||
and not recently_uninstalled
|
||||
and not persistently_uninstalled
|
||||
)
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
|
||||
@@ -7,7 +7,6 @@ from both the official registry and custom GitHub repositories.
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -20,7 +19,7 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any, Tuple, Set
|
||||
from typing import List, Dict, Optional, Any, Tuple
|
||||
import logging
|
||||
|
||||
from urllib.parse import urlparse
|
||||
@@ -44,24 +43,13 @@ class PluginStoreManager:
|
||||
"""
|
||||
|
||||
REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json"
|
||||
|
||||
# A valid plugin id is a single path component: starts alphanumeric, then
|
||||
# alphanumerics / dot / dash / underscore. Used to keep the uninstall
|
||||
# registry from ever turning a corrupt or hand-edited entry (e.g. "",
|
||||
# "..", "../x") into a filesystem path that purge_uninstalled_plugins
|
||||
# would delete — an empty id resolves to the plugins root itself.
|
||||
_PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
def __init__(self, plugins_dir: str = "plugins",
|
||||
uninstalled_registry_path: Optional[str] = None):
|
||||
def __init__(self, plugins_dir: str = "plugins"):
|
||||
"""
|
||||
Initialize the plugin store manager.
|
||||
|
||||
Args:
|
||||
plugins_dir: Directory where plugins are installed
|
||||
uninstalled_registry_path: Path to the JSON file recording plugins
|
||||
the user has uninstalled. Defaults to
|
||||
``config/uninstalled_plugins.json`` under the project root.
|
||||
"""
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
@@ -96,25 +84,6 @@ class PluginStoreManager:
|
||||
self._uninstall_tombstones: Dict[str, float] = {}
|
||||
self._uninstall_tombstone_ttl = 300 # 5 minutes
|
||||
|
||||
# Persistent record of plugins the user has uninstalled. Unlike the
|
||||
# in-memory tombstones above (a short-lived race guard), this survives
|
||||
# restarts so that a core ``git pull`` update cannot resurrect a
|
||||
# built-in plugin the user removed. Built-in plugins (e.g.
|
||||
# ``web-ui-info``, ``starlark-apps``) are committed into the repo under
|
||||
# ``plugin-repos/``, so a plain ``git pull`` restores their files even
|
||||
# after the user deleted them. ``purge_uninstalled_plugins`` re-removes
|
||||
# any such resurrected directory; ``install_plugin`` clears the record
|
||||
# when the user deliberately reinstalls. The file is gitignored.
|
||||
if uninstalled_registry_path is not None:
|
||||
self._uninstalled_registry_path = Path(uninstalled_registry_path)
|
||||
else:
|
||||
self._uninstalled_registry_path = (
|
||||
Path(__file__).parent.parent.parent / "config" / "uninstalled_plugins.json"
|
||||
)
|
||||
# Serializes read-modify-write of the registry file so concurrent
|
||||
# install/uninstall requests can't lose updates.
|
||||
self._uninstalled_registry_lock = threading.Lock()
|
||||
|
||||
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
|
||||
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
||||
# head_contents) so a fast-forward update to the current branch
|
||||
@@ -174,135 +143,6 @@ class PluginStoreManager:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _is_valid_plugin_id(self, plugin_id: Any) -> bool:
|
||||
"""Return True if ``plugin_id`` is a safe single-component plugin id.
|
||||
|
||||
Rejects empty strings, anything with a path separator, and traversal
|
||||
sequences like ``..`` so a registry entry can never escape (or target
|
||||
the root of) ``self.plugins_dir`` during a purge.
|
||||
"""
|
||||
return isinstance(plugin_id, str) and bool(self._PLUGIN_ID_RE.match(plugin_id))
|
||||
|
||||
def _read_uninstalled_registry(self) -> Set[str]:
|
||||
"""Read the persistent set of uninstalled plugin IDs.
|
||||
|
||||
Returns an empty set if the file is missing, unreadable, or corrupt —
|
||||
a broken registry must never block normal plugin operations. Invalid
|
||||
ids are dropped here so callers never turn them into paths.
|
||||
"""
|
||||
try:
|
||||
if not self._uninstalled_registry_path.exists():
|
||||
return set()
|
||||
with open(self._uninstalled_registry_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list):
|
||||
self.logger.warning(
|
||||
"Uninstalled-plugin registry at %s is not a list; ignoring it",
|
||||
self._uninstalled_registry_path,
|
||||
)
|
||||
return set()
|
||||
valid: Set[str] = set()
|
||||
for pid in data:
|
||||
if self._is_valid_plugin_id(pid):
|
||||
valid.add(pid)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Ignoring invalid plugin id in uninstall registry: %r", pid
|
||||
)
|
||||
return valid
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(
|
||||
"Could not read uninstalled-plugin registry at %s: %s",
|
||||
self._uninstalled_registry_path, e,
|
||||
)
|
||||
return set()
|
||||
|
||||
def _write_uninstalled_registry(self, plugin_ids: Set[str]) -> None:
|
||||
"""Persist the set of uninstalled plugin IDs (sorted, atomically)."""
|
||||
path = self._uninstalled_registry_path
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(sorted(plugin_ids), f, indent=2)
|
||||
os.replace(tmp_path, path)
|
||||
except OSError as e:
|
||||
self.logger.error(
|
||||
"Failed to write uninstalled-plugin registry at %s: %s", path, e
|
||||
)
|
||||
|
||||
def record_uninstalled_plugin(self, plugin_id: str) -> None:
|
||||
"""Persistently record that the user uninstalled ``plugin_id``.
|
||||
|
||||
Survives restarts so a core update cannot resurrect the plugin.
|
||||
"""
|
||||
if not self._is_valid_plugin_id(plugin_id):
|
||||
self.logger.error("Refusing to record invalid plugin id: %r", plugin_id)
|
||||
return
|
||||
with self._uninstalled_registry_lock:
|
||||
recorded = self._read_uninstalled_registry()
|
||||
if plugin_id not in recorded:
|
||||
recorded.add(plugin_id)
|
||||
self._write_uninstalled_registry(recorded)
|
||||
self.logger.info("Recorded %s as uninstalled (persistent)", plugin_id)
|
||||
|
||||
def forget_uninstalled_plugin(self, *plugin_ids: str) -> None:
|
||||
"""Drop ``plugin_ids`` from the persistent uninstall registry.
|
||||
|
||||
Called when a plugin is deliberately (re)installed so future updates
|
||||
keep it.
|
||||
"""
|
||||
with self._uninstalled_registry_lock:
|
||||
recorded = self._read_uninstalled_registry()
|
||||
to_remove = {pid for pid in plugin_ids if pid in recorded}
|
||||
if to_remove:
|
||||
self._write_uninstalled_registry(recorded - to_remove)
|
||||
self.logger.info(
|
||||
"Cleared uninstall record for %s", ", ".join(sorted(to_remove))
|
||||
)
|
||||
|
||||
def get_uninstalled_plugins(self) -> Set[str]:
|
||||
"""Return the persistent set of user-uninstalled plugin IDs."""
|
||||
return self._read_uninstalled_registry()
|
||||
|
||||
def is_plugin_uninstalled(self, plugin_id: str) -> bool:
|
||||
"""Return True if ``plugin_id`` is in the persistent uninstall registry."""
|
||||
return plugin_id in self._read_uninstalled_registry()
|
||||
|
||||
def purge_uninstalled_plugins(self) -> List[str]:
|
||||
"""Remove on-disk directories for plugins the user has uninstalled.
|
||||
|
||||
Built-in plugins committed into the repo are restored on disk by a
|
||||
core ``git pull``; this re-removes any that the user previously
|
||||
uninstalled. The registry entries are kept so the purge is idempotent
|
||||
across every future update (until the user reinstalls). Returns the
|
||||
list of plugin IDs whose directories were actually removed.
|
||||
"""
|
||||
removed: List[str] = []
|
||||
plugins_root = self.plugins_dir.resolve()
|
||||
for plugin_id in sorted(self._read_uninstalled_registry()):
|
||||
plugin_path = self.plugins_dir / plugin_id
|
||||
# Defense in depth: ids are already validated on read, but never
|
||||
# remove anything that isn't a direct child of the plugins root.
|
||||
resolved = plugin_path.resolve()
|
||||
if resolved == plugins_root or resolved.parent != plugins_root:
|
||||
self.logger.error(
|
||||
"Refusing to purge unsafe plugin path for id %r", plugin_id
|
||||
)
|
||||
continue
|
||||
if not plugin_path.exists():
|
||||
continue
|
||||
self.logger.info(
|
||||
"Purging resurrected uninstalled plugin: %s", plugin_id
|
||||
)
|
||||
if self._safe_remove_directory(plugin_path):
|
||||
removed.append(plugin_id)
|
||||
else:
|
||||
self.logger.error(
|
||||
"Failed to purge resurrected plugin directory: %s", plugin_path
|
||||
)
|
||||
return removed
|
||||
|
||||
def _load_github_token(self) -> Optional[str]:
|
||||
"""
|
||||
Load GitHub API token from config_secrets.json if available.
|
||||
@@ -1184,10 +1024,6 @@ class PluginStoreManager:
|
||||
branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
|
||||
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
|
||||
|
||||
# Remember the originally-requested id so we can clear its uninstall
|
||||
# record on success even if the manifest renames the directory below.
|
||||
requested_id = plugin_id
|
||||
|
||||
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
if not plugin_info:
|
||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||
@@ -1326,9 +1162,6 @@ class PluginStoreManager:
|
||||
|
||||
branch_display = branch_used or plugin_info.get('branch') or plugin_info.get('default_branch', 'unknown')
|
||||
self.logger.info(f"Successfully installed plugin: {plugin_id} (branch {branch_display})")
|
||||
# User deliberately (re)installed this plugin — clear any persistent
|
||||
# uninstall record so future core updates keep it.
|
||||
self.forget_uninstalled_plugin(requested_id, plugin_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -214,104 +214,6 @@ class TestDisplayControllerLivePriority:
|
||||
assert controller.current_mode_index == 1
|
||||
assert controller.current_display_mode == "b"
|
||||
|
||||
# --- Round-robin between multiple simultaneous live games --------------
|
||||
|
||||
@staticmethod
|
||||
def _live_plugin(live_modes):
|
||||
"""A mock plugin that is live and reports the given live mode names."""
|
||||
p = MagicMock()
|
||||
p.has_live_priority = MagicMock(return_value=True)
|
||||
p.has_live_content = MagicMock(return_value=True)
|
||||
p.get_live_modes = MagicMock(return_value=list(live_modes))
|
||||
return p
|
||||
|
||||
def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller):
|
||||
"""A sports plugin registered under several mode keys (one per league)
|
||||
contributes each live mode once, in registration order; plugins with no
|
||||
live content are skipped."""
|
||||
controller = test_display_controller
|
||||
baseball = self._live_plugin(["baseball_live"])
|
||||
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||
idle = MagicMock()
|
||||
idle.has_live_priority = MagicMock(return_value=True)
|
||||
idle.has_live_content = MagicMock(return_value=False)
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": baseball,
|
||||
"baseball_recent": baseball,
|
||||
"soccer_fifa.world_live": soccer,
|
||||
"soccer_usa.1_live": soccer,
|
||||
"soccer_recent": soccer,
|
||||
"clock": idle,
|
||||
}
|
||||
assert controller._collect_live_modes() == [
|
||||
"baseball_live", "soccer_fifa.world_live"
|
||||
]
|
||||
|
||||
def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller):
|
||||
"""Regression: with two games live at once, the live-priority pick
|
||||
round-robins each dwell instead of pinning to the first plugin in
|
||||
registration order (the bug where a baseball game hid a live World Cup
|
||||
match)."""
|
||||
controller = test_display_controller
|
||||
baseball = self._live_plugin(["baseball_live"])
|
||||
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": baseball,
|
||||
"soccer_fifa.world_live": soccer,
|
||||
}
|
||||
# First entry into live priority from an ambient mode -> first live game.
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
# The controller switches to it; the next dwell advances to the other.
|
||||
controller.current_display_mode = "baseball_live"
|
||||
assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live"
|
||||
# And wraps back again.
|
||||
controller.current_display_mode = "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
|
||||
def test_single_live_game_holds_without_flipping(self, test_display_controller):
|
||||
"""One live game: advancing returns the same mode, so the hold is stable."""
|
||||
controller = test_display_controller
|
||||
controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])}
|
||||
controller.current_display_mode = "baseball_live"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
|
||||
def test_non_advancing_peek_does_not_rotate(self, test_display_controller):
|
||||
"""The default (advance=False) peek used by the Vegas coordinator must
|
||||
not spin the cursor: it returns the live mode already on screen."""
|
||||
controller = test_display_controller
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": self._live_plugin(["baseball_live"]),
|
||||
"soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]),
|
||||
}
|
||||
controller.current_display_mode = "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||
# From an ambient mode the peek reports the first live game (truthy).
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority() == "baseball_live"
|
||||
|
||||
def test_no_live_content_returns_none(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
idle = MagicMock()
|
||||
idle.has_live_priority = MagicMock(return_value=True)
|
||||
idle.has_live_content = MagicMock(return_value=False)
|
||||
controller.plugin_modes = {"clock": idle}
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) is None
|
||||
|
||||
def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller):
|
||||
"""A live plugin whose get_live_modes returns nothing registered falls
|
||||
back to its own '_live' mode name (legacy behavior preserved)."""
|
||||
controller = test_display_controller
|
||||
legacy = MagicMock()
|
||||
legacy.has_live_priority = MagicMock(return_value=True)
|
||||
legacy.has_live_content = MagicMock(return_value=True)
|
||||
legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"])
|
||||
controller.plugin_modes = {"hockey_live": legacy}
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) == "hockey_live"
|
||||
|
||||
|
||||
class TestDisplayControllerDynamicDuration:
|
||||
"""Test dynamic duration handling."""
|
||||
|
||||
@@ -43,115 +43,6 @@ class TestUninstallTombstone(unittest.TestCase):
|
||||
self.assertNotIn("foo", self.sm._uninstall_tombstones)
|
||||
|
||||
|
||||
class TestPersistentUninstallRegistry(unittest.TestCase):
|
||||
"""Regression tests for the persistent uninstall registry that stops a
|
||||
core `git pull` update from resurrecting built-in plugins the user
|
||||
removed (plugins committed under plugin-repos/)."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.plugins_dir = Path(self._tmp.name) / "plugin-repos"
|
||||
self.plugins_dir.mkdir()
|
||||
self.registry_path = Path(self._tmp.name) / "config" / "uninstalled_plugins.json"
|
||||
self.sm = PluginStoreManager(
|
||||
plugins_dir=str(self.plugins_dir),
|
||||
uninstalled_registry_path=str(self.registry_path),
|
||||
)
|
||||
|
||||
def _make_plugin_dir(self, plugin_id):
|
||||
"""Simulate a built-in plugin restored on disk (e.g. by git pull)."""
|
||||
d = self.plugins_dir / plugin_id
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.json").write_text('{"id": "%s"}' % plugin_id)
|
||||
return d
|
||||
|
||||
def test_unrecorded_plugin_is_not_uninstalled(self):
|
||||
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||
|
||||
def test_record_persists_across_instances(self):
|
||||
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||
self.assertTrue(self.registry_path.exists())
|
||||
# A fresh manager (simulating a service restart after update) still sees it.
|
||||
fresh = PluginStoreManager(
|
||||
plugins_dir=str(self.plugins_dir),
|
||||
uninstalled_registry_path=str(self.registry_path),
|
||||
)
|
||||
self.assertTrue(fresh.is_plugin_uninstalled("web-ui-info"))
|
||||
|
||||
def test_forget_clears_record(self):
|
||||
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||
self.sm.forget_uninstalled_plugin("web-ui-info")
|
||||
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||
|
||||
def test_purge_removes_resurrected_plugin(self):
|
||||
# The bug: user removed web-ui-info, then a git pull restored its
|
||||
# committed files. Recorded uninstall + purge must re-remove it.
|
||||
self._make_plugin_dir("web-ui-info")
|
||||
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||
self.assertTrue((self.plugins_dir / "web-ui-info").exists())
|
||||
|
||||
removed = self.sm.purge_uninstalled_plugins()
|
||||
|
||||
self.assertEqual(removed, ["web-ui-info"])
|
||||
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
|
||||
# Record is kept so the purge stays idempotent across future updates.
|
||||
self.assertTrue(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||
|
||||
def test_purge_leaves_non_uninstalled_plugins_alone(self):
|
||||
self._make_plugin_dir("baseball-scoreboard") # present, not recorded
|
||||
self._make_plugin_dir("web-ui-info")
|
||||
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||
|
||||
self.sm.purge_uninstalled_plugins()
|
||||
|
||||
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
|
||||
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
|
||||
|
||||
def test_purge_noop_when_plugin_absent(self):
|
||||
# Recorded but never restored on disk — nothing to remove.
|
||||
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||
self.assertEqual(self.sm.purge_uninstalled_plugins(), [])
|
||||
|
||||
def test_corrupt_registry_is_ignored(self):
|
||||
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.registry_path.write_text("{ not valid json")
|
||||
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||
|
||||
def _write_raw_registry(self, value):
|
||||
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
import json as _json
|
||||
self.registry_path.write_text(_json.dumps(value))
|
||||
|
||||
def test_empty_id_does_not_wipe_plugins_root(self):
|
||||
# An empty id resolves to plugins_dir itself; purge must never delete it.
|
||||
self._make_plugin_dir("baseball-scoreboard")
|
||||
self._write_raw_registry([""])
|
||||
|
||||
removed = self.sm.purge_uninstalled_plugins()
|
||||
|
||||
self.assertEqual(removed, [])
|
||||
self.assertTrue(self.plugins_dir.exists())
|
||||
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
|
||||
# Invalid id is filtered out entirely.
|
||||
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||
|
||||
def test_traversal_ids_are_ignored(self):
|
||||
for bad in ["..", "../evil", "a/b", "."]:
|
||||
with self.subTest(bad=bad):
|
||||
self.assertFalse(self.sm._is_valid_plugin_id(bad))
|
||||
self._write_raw_registry(["../evil", "..", "web-ui-info"])
|
||||
# Only the safe id survives the read.
|
||||
self.assertEqual(self.sm.get_uninstalled_plugins(), {"web-ui-info"})
|
||||
|
||||
def test_record_rejects_invalid_id(self):
|
||||
self.sm.record_uninstalled_plugin("")
|
||||
self.sm.record_uninstalled_plugin("../escape")
|
||||
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||
|
||||
|
||||
class TestGitInfoCache(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""
|
||||
Regression test for saving plugin config fields whose schema keys contain dots
|
||||
(e.g. soccer league keys like "fifa.world", "eng.1", "usa.1").
|
||||
|
||||
Bug: the web config form posts form-data with dotted paths such as
|
||||
"leagues.fifa.world.enabled". The helpers that resolve those paths split on every
|
||||
dot, so the dotted league key "fifa.world" was mistaken for nested "fifa" ->
|
||||
"world" objects. Per-league edits (enable, favorite_teams, nested booleans) were
|
||||
written to a fabricated "leagues.fifa.world" branch while the real league object
|
||||
was never updated, so the save silently dropped the change and the saved config
|
||||
came out byte-identical.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
_get_schema_property,
|
||||
_set_nested_value,
|
||||
_parse_form_value_with_schema,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leagues": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fifa.world": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"favorite_teams": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"display_modes": {
|
||||
"type": "object",
|
||||
"properties": {"live": {"type": "boolean"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestDottedLeagueKeys(unittest.TestCase):
|
||||
def test_schema_lookup_resolves_dotted_league_key(self):
|
||||
prop = _get_schema_property(SCHEMA, "leagues.fifa.world.favorite_teams")
|
||||
self.assertIsNotNone(prop, "dotted league key path should resolve")
|
||||
self.assertEqual(prop.get("type"), "array")
|
||||
|
||||
def test_schema_lookup_resolves_nested_object_beneath_dotted_key(self):
|
||||
live = _get_schema_property(SCHEMA, "leagues.fifa.world.display_modes.live")
|
||||
self.assertIsNotNone(live)
|
||||
self.assertEqual(live.get("type"), "boolean")
|
||||
|
||||
def test_parse_typed_value_for_dotted_key(self):
|
||||
# Comma-separated text input "USA" must become an array, not the raw string.
|
||||
parsed = _parse_form_value_with_schema(
|
||||
"USA", "leagues.fifa.world.favorite_teams", SCHEMA
|
||||
)
|
||||
self.assertEqual(parsed, ["USA"])
|
||||
|
||||
def test_set_value_updates_real_league_not_fabricated_branch(self):
|
||||
config = {"leagues": {"fifa.world": {"enabled": False, "favorite_teams": []}}}
|
||||
_set_nested_value(config, "leagues.fifa.world.enabled", True)
|
||||
_set_nested_value(config, "leagues.fifa.world.favorite_teams", ["USA"])
|
||||
|
||||
self.assertTrue(config["leagues"]["fifa.world"]["enabled"])
|
||||
self.assertEqual(config["leagues"]["fifa.world"]["favorite_teams"], ["USA"])
|
||||
# The real league must be updated and no fabricated "fifa" branch created.
|
||||
self.assertNotIn("fifa", config["leagues"])
|
||||
|
||||
def test_set_value_into_missing_leaf_lands_in_real_league(self):
|
||||
# A leaf that does not exist yet still resolves into the real dotted league.
|
||||
config = {"leagues": {"fifa.world": {"enabled": False}}}
|
||||
_set_nested_value(config, "leagues.fifa.world.display_modes.live", True)
|
||||
self.assertTrue(
|
||||
config["leagues"]["fifa.world"]["display_modes"]["live"]
|
||||
)
|
||||
self.assertNotIn("fifa", config["leagues"])
|
||||
|
||||
def test_plain_nested_paths_still_work(self):
|
||||
config = {}
|
||||
_set_nested_value(config, "customization.text.font", "small")
|
||||
self.assertEqual(config["customization"]["text"]["font"], "small")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -79,21 +79,6 @@ plugin_manager = PluginManager(
|
||||
cache_manager=None # Not needed for web interface
|
||||
)
|
||||
plugin_store_manager = PluginStoreManager(plugins_dir=str(plugins_dir))
|
||||
# A core `git pull` update (or any checkout) restores built-in plugins
|
||||
# committed under plugin-repos/, even ones the user uninstalled. Re-remove any
|
||||
# the user previously uninstalled at startup so a manual update on the Pi
|
||||
# doesn't resurrect them.
|
||||
try:
|
||||
_purged = plugin_store_manager.purge_uninstalled_plugins()
|
||||
if _purged:
|
||||
logging.getLogger(__name__).info(
|
||||
"Re-removed %d uninstalled plugin(s) restored since last run: %s",
|
||||
len(_purged), ", ".join(_purged),
|
||||
)
|
||||
except (OSError, RuntimeError) as _purge_err:
|
||||
logging.getLogger(__name__).warning(
|
||||
"Startup plugin purge failed: %s", _purge_err
|
||||
)
|
||||
saved_repositories_manager = SavedRepositoriesManager()
|
||||
|
||||
# Initialize schema manager
|
||||
|
||||
@@ -705,7 +705,8 @@ def save_main_config():
|
||||
display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping',
|
||||
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
|
||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
|
||||
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type']
|
||||
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type',
|
||||
'row_address_type']
|
||||
|
||||
if any(k in data for k in display_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -736,14 +737,23 @@ def save_main_config():
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
|
||||
|
||||
# Validate row_address_type
|
||||
if 'row_address_type' in data:
|
||||
try:
|
||||
rat_val = int(data['row_address_type'])
|
||||
if rat_val < 0 or rat_val > 4:
|
||||
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
|
||||
|
||||
# Handle hardware settings
|
||||
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||
'led_rgb_sequence', 'multiplexing', 'panel_type']:
|
||||
'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type']:
|
||||
if field in data:
|
||||
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
|
||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||
'multiplexing']:
|
||||
'multiplexing', 'row_address_type']:
|
||||
current_config['display']['hardware'][field] = int(data[field])
|
||||
else:
|
||||
current_config['display']['hardware'][field] = data[field]
|
||||
@@ -1559,20 +1569,6 @@ def execute_system_action():
|
||||
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
|
||||
if result.stdout and "Already up to date" not in result.stdout:
|
||||
pull_message = f"Code updated successfully.{stash_info}"
|
||||
# A `git pull` restores built-in plugins (committed under
|
||||
# plugin-repos/) even if the user uninstalled them. Re-remove
|
||||
# any the user previously uninstalled so the update doesn't
|
||||
# resurrect them.
|
||||
if api_v3.plugin_store_manager:
|
||||
try:
|
||||
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
|
||||
if purged:
|
||||
logger.info(
|
||||
"Re-removed %d uninstalled plugin(s) restored by update: %s",
|
||||
len(purged), ", ".join(purged),
|
||||
)
|
||||
except (OSError, RuntimeError) as purge_err:
|
||||
logger.warning("Post-update plugin purge failed: %s", purge_err)
|
||||
else:
|
||||
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
||||
pull_message = "Update failed; check logs for details"
|
||||
@@ -1588,6 +1584,66 @@ def execute_system_action():
|
||||
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'install_base_requirements':
|
||||
req_file = PROJECT_ROOT / 'requirements.txt'
|
||||
if not req_file.exists():
|
||||
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req_file)],
|
||||
capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'success' if result.returncode == 0 else 'error',
|
||||
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
|
||||
'output': (result.stdout + result.stderr).strip()
|
||||
})
|
||||
elif action == 'install_plugin_requirements':
|
||||
plugins_dir = Path(plugin_manager.plugins_dir) if plugin_manager else PROJECT_ROOT / 'plugin-repos'
|
||||
results = []
|
||||
if plugins_dir.exists():
|
||||
for p in sorted(plugins_dir.iterdir()):
|
||||
req = p / 'requirements.txt'
|
||||
if p.is_dir() and req.exists():
|
||||
r = subprocess.run(
|
||||
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req)],
|
||||
capture_output=True, text=True, timeout=60
|
||||
)
|
||||
results.append({
|
||||
'plugin': p.name,
|
||||
'ok': r.returncode == 0,
|
||||
'output': (r.stdout + r.stderr).strip()
|
||||
})
|
||||
ok_count = sum(1 for r in results if r['ok'])
|
||||
all_ok = all(r['ok'] for r in results) if results else True
|
||||
return jsonify({
|
||||
'status': 'success' if all_ok else 'error',
|
||||
'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found',
|
||||
'details': results
|
||||
})
|
||||
elif action == 'force_git_reset':
|
||||
project_dir = str(PROJECT_ROOT)
|
||||
fetch = subprocess.run(
|
||||
['git', 'fetch', 'origin'],
|
||||
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()})
|
||||
reset = subprocess.run(
|
||||
['git', 'reset', '--hard', 'origin/main'],
|
||||
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'success' if reset.returncode == 0 else 'error',
|
||||
'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed',
|
||||
'output': (reset.stdout + reset.stderr).strip()
|
||||
})
|
||||
elif action == 'clear_pycache':
|
||||
cleared = 0
|
||||
for d in PROJECT_ROOT.rglob('__pycache__'):
|
||||
if d.is_dir():
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
cleared += 1
|
||||
return jsonify({'status': 'success', 'message': f'Cleared {cleared} __pycache__ directories'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
||||
|
||||
@@ -1610,6 +1666,27 @@ def execute_system_action():
|
||||
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
|
||||
|
||||
@api_v3.route('/system/git-info', methods=['GET'])
|
||||
def get_git_info():
|
||||
"""Return branch, dirty state, recent commits and remote URL for the Tools tab."""
|
||||
d = str(PROJECT_ROOT)
|
||||
try:
|
||||
branch = subprocess.run(['git', 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||
status = subprocess.run(['git', 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d)
|
||||
log = subprocess.run(['git', 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||
remote = subprocess.run(['git', 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||
return jsonify({
|
||||
'branch': branch.stdout.strip(),
|
||||
'dirty': bool(status.stdout.strip()),
|
||||
'status': status.stdout.strip(),
|
||||
'recent_commits': log.stdout.strip(),
|
||||
'remote_url': remote.stdout.strip(),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("get_git_info failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
|
||||
|
||||
|
||||
@api_v3.route('/hardware/status', methods=['GET'])
|
||||
def get_hardware_status():
|
||||
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
||||
@@ -2947,13 +3024,6 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
|
||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||
if api_v3.plugin_state_manager:
|
||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
||||
# Persistently record the uninstall so a later core `git pull` update
|
||||
# cannot resurrect a built-in plugin (committed under plugin-repos/) that
|
||||
# the user removed. Best-effort: never fail the uninstall over this.
|
||||
try:
|
||||
api_v3.plugin_store_manager.record_uninstalled_plugin(plugin_id)
|
||||
except Exception as record_err:
|
||||
logger.warning("Could not record uninstall for %s: %s", plugin_id, record_err)
|
||||
return True, None
|
||||
|
||||
|
||||
@@ -3558,29 +3628,21 @@ def _get_schema_property(schema, key_path):
|
||||
|
||||
parts = key_path.split('.')
|
||||
current = schema['properties']
|
||||
i = 0
|
||||
|
||||
while i < len(parts):
|
||||
# Try progressively longer candidates, longest first, so schema keys that
|
||||
# themselves contain dots (e.g. league keys like "fifa.world") are matched
|
||||
# instead of being mistaken for nested "fifa" -> "world" objects.
|
||||
matched = False
|
||||
for j in range(len(parts), i, -1):
|
||||
candidate = '.'.join(parts[i:j])
|
||||
if isinstance(current, dict) and candidate in current:
|
||||
prop = current[candidate]
|
||||
# Consumed all remaining parts — this is the target property.
|
||||
if j == len(parts):
|
||||
return prop
|
||||
# Navigate deeper through an object with properties.
|
||||
if isinstance(prop, dict) and 'properties' in prop:
|
||||
current = prop['properties']
|
||||
i = j
|
||||
matched = True
|
||||
break
|
||||
# Matched a non-object before consuming the path — can't go deeper.
|
||||
return None
|
||||
if not matched:
|
||||
for i, part in enumerate(parts):
|
||||
if part not in current:
|
||||
return None
|
||||
|
||||
prop = current[part]
|
||||
|
||||
# If this is the last part, return the property
|
||||
if i == len(parts) - 1:
|
||||
return prop
|
||||
|
||||
# If this is an object with properties, navigate deeper
|
||||
if isinstance(prop, dict) and 'properties' in prop:
|
||||
current = prop['properties']
|
||||
else:
|
||||
return None
|
||||
|
||||
return None
|
||||
@@ -3753,45 +3815,10 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_key_segments(key_path, config):
|
||||
"""Split a dot-notation path into segments, greedily preserving keys that
|
||||
themselves contain dots (e.g. league keys like "fifa.world").
|
||||
|
||||
At each level the longest candidate that matches a key already present in the
|
||||
config wins; otherwise the path splits on the next dot (the normal
|
||||
nested-create case). Because dotted keys such as ``leagues."fifa.world"``
|
||||
always exist in the saved config being updated, this routes the value to the
|
||||
real league object instead of fabricating a ``leagues.fifa.world`` tree.
|
||||
"""
|
||||
parts = key_path.split('.')
|
||||
segments = []
|
||||
node = config
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
matched = False
|
||||
if isinstance(node, dict):
|
||||
for j in range(len(parts), i, -1):
|
||||
candidate = '.'.join(parts[i:j])
|
||||
if candidate in node:
|
||||
segments.append(candidate)
|
||||
node = node[candidate]
|
||||
i = j
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
part = parts[i]
|
||||
segments.append(part)
|
||||
node = node.get(part) if isinstance(node, dict) else None
|
||||
i += 1
|
||||
return segments
|
||||
|
||||
|
||||
def _set_nested_value(config, key_path, value):
|
||||
"""
|
||||
Set a value in a nested dict using dot notation path.
|
||||
Handles existing nested dicts correctly by merging instead of replacing.
|
||||
Keys containing dots (e.g. league keys like "fifa.world") are preserved when
|
||||
they already exist in the config rather than being split into nested objects.
|
||||
|
||||
Args:
|
||||
config: The config dict to modify
|
||||
@@ -3801,22 +3828,22 @@ def _set_nested_value(config, key_path, value):
|
||||
# Skip setting if value is the sentinel
|
||||
if value is _SKIP_FIELD:
|
||||
return
|
||||
|
||||
segments = _resolve_key_segments(key_path, config)
|
||||
|
||||
parts = key_path.split('.')
|
||||
current = config
|
||||
|
||||
# Navigate/create intermediate dicts
|
||||
for seg in segments[:-1]:
|
||||
if seg not in current:
|
||||
current[seg] = {}
|
||||
elif not isinstance(current[seg], dict):
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
elif not isinstance(current[part], dict):
|
||||
# If the existing value is not a dict, replace it with a dict
|
||||
current[seg] = {}
|
||||
current = current[seg]
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
|
||||
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
|
||||
if value is not None or segments[-1] not in current:
|
||||
current[segments[-1]] = value
|
||||
if value is not None or parts[-1] not in current:
|
||||
current[parts[-1]] = value
|
||||
|
||||
|
||||
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
||||
|
||||
@@ -90,6 +90,8 @@ def load_partial(partial_name):
|
||||
return _load_cache_partial()
|
||||
elif partial_name == 'operation-history':
|
||||
return _load_operation_history_partial()
|
||||
elif partial_name == 'tools':
|
||||
return _load_tools_partial()
|
||||
else:
|
||||
return "Partial not found", 404
|
||||
|
||||
@@ -448,6 +450,15 @@ def _load_operation_history_partial():
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
def _load_tools_partial():
|
||||
"""Load tools/utilities partial."""
|
||||
try:
|
||||
return render_template('v3/partials/tools.html')
|
||||
except Exception:
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
def _load_plugin_config_partial(plugin_id):
|
||||
"""
|
||||
Load plugin configuration partial - server-side rendered form.
|
||||
|
||||
@@ -1009,6 +1009,11 @@
|
||||
class="nav-tab">
|
||||
<i class="fas fa-history"></i>Operation History
|
||||
</button>
|
||||
<button @click="activeTab = 'tools'"
|
||||
:class="activeTab === 'tools' ? 'nav-tab-active' : ''"
|
||||
class="nav-tab">
|
||||
<i class="fas fa-tools"></i>Tools
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1290,6 +1295,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools tab -->
|
||||
<div x-show="activeTab === 'tools'" x-transition>
|
||||
<div id="tools-content" hx-get="/v3/partials/tools" 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>
|
||||
<div class="h-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Plugin Tabs - HTMX Lazy Loading -->
|
||||
<!--
|
||||
Architecture: Server-side rendered plugin configuration forms
|
||||
|
||||
@@ -166,6 +166,18 @@
|
||||
</select>
|
||||
<p class="mt-1 text-sm text-gray-600">Special panel chipset initialization (use Standard unless your panel requires it)</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type</label>
|
||||
<select id="row_address_type" name="row_address_type" class="form-control">
|
||||
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option>
|
||||
<option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
|
||||
<option value="2" {% if main_config.display.hardware.get('row_address_type', 0)|int == 2 %}selected{% endif %}>2 - Row direct</option>
|
||||
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
|
||||
<option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
|
||||
</select>
|
||||
<p class="mt-1 text-sm text-gray-600">Row addressing scheme — leave at Default (0) unless your panel requires a specific type</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
306
web_interface/templates/v3/partials/tools.html
Normal file
306
web_interface/templates/v3/partials/tools.html
Normal file
@@ -0,0 +1,306 @@
|
||||
<div class="space-y-6" id="tools-root">
|
||||
|
||||
<!-- Git & Updates -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Git & Updates</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Inspect the current git state and pull or reset to the latest remote code.</p>
|
||||
</div>
|
||||
|
||||
<!-- Git status info -->
|
||||
<div id="git-info-panel" class="mb-6 bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm">
|
||||
<div class="animate-pulse text-gray-400">Loading git info…</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Pull latest -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Pull latest (rebase)</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Stashes local changes, runs <code class="bg-gray-100 px-1 rounded">git pull --rebase</code>, then restores the stash.</p>
|
||||
</div>
|
||||
<button id="btn-git-pull" onclick="toolsAction('git_pull', 'btn-git-pull', 'result-git-pull')"
|
||||
class="shrink-0 inline-flex items-center px-3 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>Pull Latest
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-git-pull" class="hidden"></div>
|
||||
|
||||
<!-- Force reset -->
|
||||
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Force reset to <code class="bg-gray-100 px-1 rounded">origin/main</code></p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">git fetch origin</code> then <code class="bg-gray-100 px-1 rounded">git reset --hard origin/main</code>. Discards all local changes.</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex flex-col items-end gap-2">
|
||||
<button id="btn-force-reset-confirm" onclick="showForceResetConfirm()"
|
||||
class="inline-flex items-center px-3 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>Force Reset…
|
||||
</button>
|
||||
<div id="force-reset-confirm-row" class="hidden flex items-center gap-2">
|
||||
<span class="text-xs text-red-700 font-medium">This discards all local changes. Sure?</span>
|
||||
<button onclick="toolsAction('force_git_reset', 'btn-force-reset-confirm', 'result-force-reset'); hideForceResetConfirm()"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||
Yes, reset
|
||||
</button>
|
||||
<button onclick="hideForceResetConfirm()"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="result-force-reset" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Python Dependencies -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Python Dependencies</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Re-run <code class="bg-gray-100 px-1 rounded">pip install</code> to fix missing or broken packages.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Base requirements -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Reinstall base requirements</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Installs from <code class="bg-gray-100 px-1 rounded">requirements.txt</code> in the project root.</p>
|
||||
</div>
|
||||
<button id="btn-base-reqs" onclick="toolsAction('install_base_requirements', 'btn-base-reqs', 'result-base-reqs', true)"
|
||||
class="shrink-0 inline-flex items-center px-3 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-box mr-2"></i>Reinstall Base
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-base-reqs" class="hidden"></div>
|
||||
|
||||
<!-- Plugin requirements -->
|
||||
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Reinstall plugin requirements</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">pip install</code> for every installed plugin that has a <code class="bg-gray-100 px-1 rounded">requirements.txt</code>.</p>
|
||||
</div>
|
||||
<button id="btn-plugin-reqs" onclick="toolsAction('install_plugin_requirements', 'btn-plugin-reqs', 'result-plugin-reqs', false, true)"
|
||||
class="shrink-0 inline-flex items-center px-3 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-puzzle-piece mr-2"></i>Reinstall Plugin Deps
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-plugin-reqs" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Maintenance</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Housekeeping operations that don't affect config or plugins.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Clear Python cache</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Deletes all <code class="bg-gray-100 px-1 rounded">__pycache__</code> directories in the project. Useful after switching branches or debugging import issues.</p>
|
||||
</div>
|
||||
<button id="btn-clear-pycache" onclick="toolsAction('clear_pycache', 'btn-clear-pycache', 'result-clear-pycache')"
|
||||
class="shrink-0 inline-flex items-center px-3 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-broom mr-2"></i>Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-clear-pycache" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Services</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Quick access to service restarts.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Restart display service</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix.service</code>.</p>
|
||||
</div>
|
||||
<button id="btn-restart-display" onclick="toolsAction('restart_display_service', 'btn-restart-display', 'result-restart-display')"
|
||||
class="shrink-0 inline-flex items-center px-3 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-sync-alt mr-2"></i>Restart Display
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-restart-display" class="hidden"></div>
|
||||
|
||||
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Restart web interface</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix-web.service</code>. The page will go offline briefly.</p>
|
||||
</div>
|
||||
<button id="btn-restart-web" onclick="toolsAction('restart_web_service', 'btn-restart-web', 'result-restart-web')"
|
||||
class="shrink-0 inline-flex items-center px-3 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-globe mr-2"></i>Restart Web
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-restart-web" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function setBusy(btnId, busy) {
|
||||
const btn = document.getElementById(btnId);
|
||||
if (!btn) return;
|
||||
btn.disabled = busy;
|
||||
btn.style.opacity = busy ? '0.6' : '';
|
||||
btn.style.cursor = busy ? 'wait' : '';
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
if (busy) {
|
||||
icon.dataset.origClass = icon.className;
|
||||
icon.className = 'fas fa-spinner fa-spin mr-2';
|
||||
} else if (icon.dataset.origClass) {
|
||||
icon.className = icon.dataset.origClass;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showResult(resultId, ok, message, output, pluginDetails) {
|
||||
const el = document.getElementById(resultId);
|
||||
if (!el) return;
|
||||
el.classList.remove('hidden');
|
||||
|
||||
const color = ok ? 'green' : 'red';
|
||||
const icon = ok ? 'fa-check-circle' : 'fa-times-circle';
|
||||
|
||||
let html = `
|
||||
<div class="mt-3 rounded-md p-3 bg-${color}-50 border border-${color}-200">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas ${icon} text-${color}-600 mt-0.5"></i>
|
||||
<span class="text-sm text-${color}-800">${escHtml(message)}</span>
|
||||
</div>`;
|
||||
|
||||
if (output) {
|
||||
html += `
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-${color}-700 cursor-pointer hover:underline">Show output</summary>
|
||||
<pre class="mt-2 text-xs bg-gray-900 text-gray-100 rounded p-3 overflow-x-auto whitespace-pre-wrap">${escHtml(output)}</pre>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
if (pluginDetails && pluginDetails.length > 0) {
|
||||
html += `<ul class="mt-3 space-y-1">`;
|
||||
for (const d of pluginDetails) {
|
||||
const dc = d.ok ? 'green' : 'red';
|
||||
const di = d.ok ? 'fa-check' : 'fa-times';
|
||||
html += `<li class="text-xs flex items-start gap-1">
|
||||
<i class="fas ${di} text-${dc}-600 mt-0.5 w-3"></i>
|
||||
<span class="text-gray-700">${escHtml(d.plugin)}</span>`;
|
||||
if (d.output) {
|
||||
html += ` <details class="inline"><summary class="cursor-pointer text-gray-400 hover:underline ml-1">output</summary>
|
||||
<pre class="mt-1 text-xs bg-gray-900 text-gray-100 rounded p-2 overflow-x-auto whitespace-pre-wrap">${escHtml(d.output)}</pre></details>`;
|
||||
}
|
||||
html += `</li>`;
|
||||
}
|
||||
html += `</ul>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── main action dispatcher ────────────────────────────────────────────────
|
||||
|
||||
window.toolsAction = function(action, btnId, resultId, showOutput, showPluginDetails) {
|
||||
setBusy(btnId, true);
|
||||
const el = document.getElementById(resultId);
|
||||
if (el) el.classList.add('hidden');
|
||||
|
||||
fetch('/api/v3/system/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const ok = data.status === 'success';
|
||||
showResult(
|
||||
resultId, ok,
|
||||
data.message || (ok ? 'Done' : 'Failed'),
|
||||
showOutput ? (data.output || '') : '',
|
||||
showPluginDetails ? (data.details || []) : null
|
||||
);
|
||||
})
|
||||
.catch(err => {
|
||||
showResult(resultId, false, 'Request failed: ' + err.message);
|
||||
})
|
||||
.finally(() => setBusy(btnId, false));
|
||||
};
|
||||
|
||||
// ── force-reset confirm helpers ───────────────────────────────────────────
|
||||
|
||||
window.showForceResetConfirm = function() {
|
||||
document.getElementById('force-reset-confirm-row').classList.remove('hidden');
|
||||
document.getElementById('btn-force-reset-confirm').classList.add('hidden');
|
||||
};
|
||||
|
||||
window.hideForceResetConfirm = function() {
|
||||
document.getElementById('force-reset-confirm-row').classList.add('hidden');
|
||||
document.getElementById('btn-force-reset-confirm').classList.remove('hidden');
|
||||
};
|
||||
|
||||
// ── git info panel ────────────────────────────────────────────────────────
|
||||
|
||||
function loadGitInfo() {
|
||||
const panel = document.getElementById('git-info-panel');
|
||||
if (!panel) return;
|
||||
|
||||
fetch('/api/v3/system/git-info')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const dirtyBadge = d.dirty
|
||||
? '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">dirty</span>'
|
||||
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">clean</span>';
|
||||
|
||||
let html = `<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-code-branch text-gray-400"></i>
|
||||
<span class="font-mono text-gray-800">${escHtml(d.branch || 'unknown')}</span>
|
||||
${dirtyBadge}
|
||||
</div>`;
|
||||
|
||||
if (d.dirty && d.status) {
|
||||
html += `<pre class="text-xs bg-yellow-50 border border-yellow-200 rounded p-2 overflow-x-auto whitespace-pre-wrap text-yellow-900">${escHtml(d.status)}</pre>`;
|
||||
}
|
||||
|
||||
if (d.recent_commits) {
|
||||
html += `<div class="mt-2">
|
||||
<p class="text-xs text-gray-500 mb-1">Recent commits</p>
|
||||
<pre class="text-xs bg-gray-50 border border-gray-200 rounded p-2 overflow-x-auto whitespace-pre-wrap text-gray-700">${escHtml(d.recent_commits)}</pre>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (d.remote_url) {
|
||||
html += `<p class="text-xs text-gray-400 mt-1"><i class="fas fa-cloud mr-1"></i>${escHtml(d.remote_url)}</p>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
panel.innerHTML = html;
|
||||
})
|
||||
.catch(() => {
|
||||
panel.innerHTML = '<span class="text-sm text-red-600">Could not load git info.</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// Load on first render; HTMX will have already swapped us in by this point.
|
||||
loadGitInfo();
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user