mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 17:28:05 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c80d934a | ||
|
|
05e7c43b27 | ||
|
|
2ffc57cf40 | ||
|
|
aab0e9ade0 | ||
|
|
978a03b42d | ||
|
|
bd9f461f70 | ||
|
|
3b93024993 | ||
|
|
85d321cf33 |
@@ -43,6 +43,9 @@ websocket-client>=1.8.0,<2.0.0
|
||||
# JSON Schema validation
|
||||
jsonschema>=4.20.0,<5.0.0
|
||||
|
||||
# Requirement specifier parsing (plugin dependency satisfaction checks)
|
||||
packaging>=23.0,<27.0
|
||||
|
||||
# Testing dependencies
|
||||
pytest>=9.0.3,<10.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Clear all plugin dependency markers to force fresh dependency check
|
||||
# Useful after updating plugins or troubleshooting dependency issues
|
||||
|
||||
echo "Clearing plugin dependency markers..."
|
||||
|
||||
# Check both possible cache locations
|
||||
CACHE_DIRS=(
|
||||
"/var/cache/ledmatrix"
|
||||
"$HOME/.cache/ledmatrix"
|
||||
)
|
||||
|
||||
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
|
||||
if [ -d "$CACHE_DIR" ]; then
|
||||
echo "Checking $CACHE_DIR..."
|
||||
marker_count=$(find "$CACHE_DIR" -name "plugin_*_deps_installed" 2>/dev/null | wc -l)
|
||||
if [ "$marker_count" -gt 0 ]; then
|
||||
echo "Found $marker_count dependency marker(s) in $CACHE_DIR"
|
||||
find "$CACHE_DIR" -name "plugin_*_deps_installed" -delete
|
||||
echo "Cleared $marker_count marker(s)"
|
||||
else
|
||||
echo "No dependency markers found in $CACHE_DIR"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Done! Dependency markers cleared."
|
||||
echo "Next startup will check and install dependencies as needed."
|
||||
|
||||
Vendored
+12
-5
@@ -68,14 +68,15 @@ class DiskCache:
|
||||
return None
|
||||
return os.path.join(self.cache_dir, f"{key}.json")
|
||||
|
||||
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
|
||||
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from disk cache.
|
||||
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
max_age: Maximum age in seconds
|
||||
|
||||
max_age: Maximum age in seconds; None disables age-based expiry
|
||||
(the record never counts as stale). Mirrors MemoryCache.get.
|
||||
|
||||
Returns:
|
||||
Cached data or None if not found or expired
|
||||
"""
|
||||
@@ -105,7 +106,13 @@ class DiskCache:
|
||||
record_ts = None
|
||||
|
||||
now = time.time()
|
||||
if record_ts is None or (now - record_ts) <= max_age:
|
||||
# max_age=None means "never expires" (mirrors MemoryCache and the
|
||||
# cache_manager docstring). Guard it explicitly — otherwise the
|
||||
# comparison below raises TypeError and the record is treated as a
|
||||
# miss, which silently breaks callers that persist long-lived state
|
||||
# via get(key, max_age=None) (e.g. plugin health/metrics that must
|
||||
# survive restarts and be read cross-process).
|
||||
if record_ts is None or max_age is None or (now - record_ts) <= max_age:
|
||||
return record
|
||||
else:
|
||||
# Stale on disk; keep file for potential diagnostics but treat as miss
|
||||
|
||||
+13
-3
@@ -574,9 +574,19 @@ class CacheManager:
|
||||
}
|
||||
return self.save_cache(data_type, cache_data)
|
||||
|
||||
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache if it exists and is not stale."""
|
||||
cached_data = self.get_cached_data(key, max_age)
|
||||
def get(self, key: str, max_age: Optional[int] = 300,
|
||||
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache if it exists and is not stale.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
max_age: Max age (seconds) for the on-disk entry; None never expires.
|
||||
memory_ttl: Max age (seconds) for the in-memory entry. Pass 0 to
|
||||
bypass the memory tier and force a fresh read from disk — used by
|
||||
cross-process readers that must observe another process's latest
|
||||
write rather than a stale first snapshot. Defaults to max_age.
|
||||
"""
|
||||
cached_data = self.get_cached_data(key, max_age, memory_ttl=memory_ttl)
|
||||
if cached_data and 'data' in cached_data:
|
||||
return cached_data['data']
|
||||
return cached_data
|
||||
|
||||
@@ -8,6 +8,7 @@ files that need to be accessible by both root service and web user.
|
||||
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
import shutil as _shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -16,6 +17,25 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
|
||||
# own error output can be logged/displayed without echoing back a private
|
||||
# index URL's embedded basic-auth secret verbatim (e.g. from a
|
||||
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
|
||||
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
|
||||
|
||||
|
||||
def _redact_url_credentials(text: Optional[str]) -> str:
|
||||
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
|
||||
|
||||
Safe to call on any subprocess output destined for logs: it only ever
|
||||
shortens/replaces the credential substring, never changes the presence
|
||||
or absence of the specific fixed phrases callers check for
|
||||
(e.g. "a password is required"), so it can't affect control flow.
|
||||
"""
|
||||
if not text:
|
||||
return text or ""
|
||||
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
|
||||
|
||||
# System directories that should never have their permissions modified
|
||||
# These directories have special system-level permissions that must be preserved
|
||||
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
||||
@@ -338,6 +358,13 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
||||
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
||||
)
|
||||
# Redact immediately: pip can echo a private index URL's embedded
|
||||
# basic-auth credentials back in its own error/progress output
|
||||
# (e.g. from a requirements.txt --index-url line). Doesn't affect
|
||||
# the fixed-phrase "denied" check below -- those phrases never
|
||||
# overlap with URL syntax.
|
||||
result.stderr = _redact_url_credentials(result.stderr)
|
||||
result.stdout = _redact_url_credentials(result.stdout)
|
||||
if result.returncode == 0:
|
||||
return result
|
||||
# Distinguish "sudo rejected this exact command line" (worth
|
||||
@@ -348,16 +375,24 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
||||
for phrase in ("a password is required", "is not allowed to run", "no tty present")
|
||||
)
|
||||
if not denied:
|
||||
# Deliberately don't interpolate req_file or the pip output here:
|
||||
# this log line is scanner-visible, and a static analyzer can't
|
||||
# tell "already redacted above" from "still raw" just by looking
|
||||
# at this call in isolation. The full (redacted) text is still
|
||||
# available to callers via the returned CompletedProcess.
|
||||
logger.warning(
|
||||
"Root pip install failed (rc=%s) for %s: %s",
|
||||
result.returncode, req_file, result.stderr.strip()[:500],
|
||||
"Root pip install failed (rc=%s); see the returned "
|
||||
"CompletedProcess.stderr for details.",
|
||||
result.returncode,
|
||||
)
|
||||
return result
|
||||
|
||||
# Same reasoning as above: no req_file / pip-output interpolation in
|
||||
# this log line, only in the returned note/CompletedProcess.
|
||||
logger.warning(
|
||||
"Root pip install wrapper denied via sudo for %s; falling back to "
|
||||
"user-level install: %s",
|
||||
req_file, result.stderr.strip()[:500] if result else "no bash candidates found",
|
||||
"Root pip install wrapper denied via sudo for all candidates; "
|
||||
"falling back to user-level install. See the returned "
|
||||
"CompletedProcess.stderr for details."
|
||||
)
|
||||
note = (
|
||||
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
|
||||
@@ -367,8 +402,7 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"safe_pip_install.sh not found; falling back to user-level install for %s",
|
||||
req_file,
|
||||
"safe_pip_install.sh not found; falling back to user-level install."
|
||||
)
|
||||
note = (
|
||||
"[safe_pip_install.sh not found; installed for the current process's "
|
||||
@@ -386,6 +420,7 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
||||
)
|
||||
result.stdout = note + (result.stdout or "")
|
||||
result.stderr = _redact_url_credentials(result.stderr)
|
||||
result.stdout = note + _redact_url_credentials(result.stdout)
|
||||
return result
|
||||
|
||||
|
||||
@@ -230,7 +230,24 @@ class DisplayController:
|
||||
cache_manager=self.cache_manager,
|
||||
font_manager=self.font_manager
|
||||
)
|
||||
|
||||
|
||||
# Activate the plugin health/metrics subsystem. PluginManager leaves
|
||||
# health_tracker/resource_monitor as None by default; wiring real
|
||||
# instances here turns on the circuit breaker (a repeatedly-failing
|
||||
# plugin's update() is skipped after consecutive failures, then
|
||||
# retried after a cooldown) and per-plugin execution-time metrics.
|
||||
# Both persist to the shared cache so the web UI can surface them.
|
||||
# Done before discovery/loading so load-time schema warnings have a
|
||||
# tracker to record against.
|
||||
try:
|
||||
from src.plugin_system.plugin_health import PluginHealthTracker
|
||||
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||
self.plugin_manager.health_tracker = PluginHealthTracker(self.cache_manager)
|
||||
self.plugin_manager.resource_monitor = PluginResourceMonitor(self.cache_manager)
|
||||
logger.info("Plugin health tracking and resource monitoring enabled")
|
||||
except Exception as e:
|
||||
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
|
||||
|
||||
# Validate plugins after plugin manager is created
|
||||
try:
|
||||
from src.startup_validator import StartupValidator
|
||||
@@ -604,18 +621,28 @@ class DisplayController:
|
||||
|
||||
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
||||
current_time_only = current_time.time()
|
||||
|
||||
|
||||
# Check if per-day schedule is configured
|
||||
days_config = schedule_config.get('days')
|
||||
|
||||
# Determine which schedule to use
|
||||
|
||||
# Determine which schedule to use. Respect an explicit 'mode' field
|
||||
# (like the dim schedule does) so a stray/legacy 'days' dict left over
|
||||
# from config migration or a prior per-day setup can't silently
|
||||
# override a user's Global schedule selection.
|
||||
mode = schedule_config.get('mode')
|
||||
mode_normalized = mode.replace('_', '-') if mode else None
|
||||
|
||||
use_per_day = False
|
||||
if days_config:
|
||||
# Check if days dict is not empty and contains current day
|
||||
if days_config and current_day in days_config:
|
||||
if mode_normalized == 'global':
|
||||
use_per_day = False
|
||||
elif mode_normalized == 'per-day':
|
||||
use_per_day = bool(days_config and current_day in days_config)
|
||||
elif days_config:
|
||||
# No explicit mode recorded (legacy config) - fall back to
|
||||
# inferring from presence of a 'days' dict for the current day.
|
||||
if current_day in days_config:
|
||||
use_per_day = True
|
||||
elif days_config:
|
||||
# Days dict exists but doesn't have current day - fall back to global
|
||||
else:
|
||||
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
|
||||
|
||||
if use_per_day:
|
||||
|
||||
@@ -52,11 +52,18 @@ class PluginHealthTracker:
|
||||
"""Get cache key for plugin health data."""
|
||||
return f"plugin_health:{plugin_id}"
|
||||
|
||||
def _load_health_state(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Load health state from cache or return defaults."""
|
||||
def _load_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||
"""Load health state from cache or return defaults.
|
||||
|
||||
``force_reload=True`` bypasses the cache manager's in-memory tier so a
|
||||
read-only consumer (e.g. the web process) observes the writer process's
|
||||
latest persisted state instead of a stale first snapshot.
|
||||
"""
|
||||
cache_key = self._get_health_key(plugin_id)
|
||||
cached = self.cache_manager.get(cache_key, max_age=None)
|
||||
|
||||
cached = self.cache_manager.get(
|
||||
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
||||
)
|
||||
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
@@ -79,10 +86,17 @@ class PluginHealthTracker:
|
||||
self.cache_manager.set(cache_key, state) # Persist indefinitely
|
||||
self._health_state[plugin_id] = state
|
||||
|
||||
def get_health_state(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Get current health state for a plugin."""
|
||||
if plugin_id not in self._health_state:
|
||||
self._health_state[plugin_id] = self._load_health_state(plugin_id)
|
||||
def get_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||
"""Get current health state for a plugin.
|
||||
|
||||
``force_reload=True`` re-reads the persisted state from the cache,
|
||||
bypassing the in-memory copy — needed by cross-process readers that
|
||||
would otherwise be pinned to the first snapshot they loaded.
|
||||
"""
|
||||
if force_reload or plugin_id not in self._health_state:
|
||||
self._health_state[plugin_id] = self._load_health_state(
|
||||
plugin_id, force_reload=force_reload
|
||||
)
|
||||
return self._health_state[plugin_id]
|
||||
|
||||
def record_success(self, plugin_id: str) -> None:
|
||||
@@ -139,6 +153,28 @@ class PluginHealthTracker:
|
||||
|
||||
self._save_health_state(plugin_id, state)
|
||||
|
||||
def set_degraded(self, plugin_id: str, reason: Optional[str]) -> None:
|
||||
"""Flag (or clear) a plugin as degraded without touching the circuit breaker.
|
||||
|
||||
Used for non-fatal issues — e.g. a config that no longer satisfies the
|
||||
plugin's schema — that should be surfaced to the user but must NOT cause
|
||||
the plugin to be skipped or counted as a runtime failure. Passing
|
||||
``reason=None`` clears the flag. The write is skipped when nothing
|
||||
actually changes, so calling this on every load is cheap.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
reason: Human-readable reason string, or None to clear the flag
|
||||
"""
|
||||
state = self.get_health_state(plugin_id)
|
||||
new_degraded = bool(reason)
|
||||
new_reason = reason if reason else None
|
||||
if state.get('degraded', False) == new_degraded and state.get('degraded_reason') == new_reason:
|
||||
return # No change — avoid a redundant cache write
|
||||
state['degraded'] = new_degraded
|
||||
state['degraded_reason'] = new_reason
|
||||
self._save_health_state(plugin_id, state)
|
||||
|
||||
def should_skip_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Check if plugin should be skipped due to circuit breaker.
|
||||
@@ -181,9 +217,13 @@ class PluginHealthTracker:
|
||||
|
||||
return False
|
||||
|
||||
def get_health_summary(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Get health summary for a plugin."""
|
||||
state = self.get_health_state(plugin_id)
|
||||
def get_health_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||
"""Get health summary for a plugin.
|
||||
|
||||
``force_reload=True`` refreshes from the persisted cache first so
|
||||
cross-process readers reflect the writer's latest state.
|
||||
"""
|
||||
state = self.get_health_state(plugin_id, force_reload=force_reload)
|
||||
|
||||
total_calls = state.get('total_successes', 0) + state.get('total_failures', 0)
|
||||
success_rate = 0.0
|
||||
@@ -201,6 +241,8 @@ class PluginHealthTracker:
|
||||
'last_failure_time': state.get('last_failure_time'),
|
||||
'last_error': state.get('last_error'),
|
||||
'is_healthy': state.get('circuit_state') == CircuitState.CLOSED.value,
|
||||
'degraded': state.get('degraded', False),
|
||||
'degraded_reason': state.get('degraded_reason'),
|
||||
'circuit_opened_time': state.get('circuit_opened_time'),
|
||||
'half_open_start_time': state.get('half_open_start_time')
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
||||
Extracted from PluginManager to improve separation of concerns.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
@@ -17,12 +17,101 @@ from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple, Type
|
||||
import logging
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
|
||||
from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
from src.common.permission_utils import (
|
||||
ensure_file_permissions,
|
||||
get_plugin_file_mode
|
||||
)
|
||||
|
||||
|
||||
def requirements_has_real_deps(requirements_file: str) -> bool:
|
||||
"""
|
||||
Check whether a requirements.txt actually specifies anything to install.
|
||||
|
||||
Plugins that ship all their dependencies with LEDMatrix core often keep a
|
||||
requirements.txt where every line is commented out, for documentation
|
||||
purposes only. Running pip against such a file still pays the full
|
||||
subprocess/resolver cost for zero effect, so callers should skip the
|
||||
install step entirely when this returns False.
|
||||
"""
|
||||
try:
|
||||
with open(requirements_file, 'r', encoding='utf-8') as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
return True
|
||||
except OSError:
|
||||
# Let the caller's own file handling report the error.
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||
"""
|
||||
Check whether every real requirement line in requirements.txt is already
|
||||
satisfied by packages installed in the current interpreter.
|
||||
|
||||
This replaces marker-file tracking with a direct fact check, so it's
|
||||
immune to stale/missing/corrupted markers: it looks at what's actually
|
||||
importable right now rather than trusting a hash comparison from a
|
||||
previous run. Anything ambiguous (pip options, unparseable lines,
|
||||
extras, unresolvable versions) conservatively returns False so the
|
||||
caller falls through to running pip — this check only ever saves work,
|
||||
never masks a real install.
|
||||
"""
|
||||
try:
|
||||
with open(requirements_file, 'r', encoding='utf-8') as fh:
|
||||
lines = fh.readlines()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
if line.startswith('-'):
|
||||
return False # pip option (-r, --index-url, ...), can't verify
|
||||
|
||||
try:
|
||||
req = Requirement(line)
|
||||
except InvalidRequirement:
|
||||
return False
|
||||
|
||||
if req.extras:
|
||||
return False # verifying extras' sub-dependencies isn't worth it here
|
||||
|
||||
if req.marker is not None and not req.marker.evaluate():
|
||||
continue # not applicable on this platform/interpreter
|
||||
|
||||
try:
|
||||
installed_version = importlib.metadata.version(req.name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return False
|
||||
|
||||
if req.specifier and not req.specifier.contains(installed_version, prereleases=True):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
|
||||
"""Return `name` if it names an actual subdirectory of trusted_dir, else None.
|
||||
|
||||
Used as a containment check for a directory name derived from untrusted
|
||||
input (a manifest-declared plugin id, an externally-supplied plugin
|
||||
path): the returned value always comes from enumerating trusted_dir
|
||||
itself via os.scandir(), so a caller that builds a path by joining
|
||||
trusted_dir with this return value is joining against a name the
|
||||
filesystem produced under a trusted root -- not the caller's original
|
||||
string, which could otherwise smuggle a traversal sequence through.
|
||||
"""
|
||||
try:
|
||||
with os.scandir(trusted_dir) as entries:
|
||||
for entry in entries:
|
||||
if entry.name == name and entry.is_dir():
|
||||
return entry.name
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
@@ -132,14 +221,14 @@ class PluginLoader:
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
|
||||
continue
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def install_dependencies(
|
||||
self,
|
||||
plugin_dir: Path,
|
||||
plugin_id: str,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
plugins_dir: Path,
|
||||
timeout: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
@@ -148,7 +237,12 @@ class PluginLoader:
|
||||
Args:
|
||||
plugin_dir: Plugin directory path
|
||||
plugin_id: Plugin identifier
|
||||
plugins_dir: Trusted base plugins directory for path containment check
|
||||
plugins_dir: Trusted base plugins directory for path containment check.
|
||||
Required (not optional) so every caller reconstructs the plugin
|
||||
path through the sanitiser below rather than trusting plugin_dir
|
||||
directly -- CodeQL's path-injection query (and a malicious
|
||||
manifest/plugin_id in practice) can't tell a legitimate
|
||||
plugin_dir from one crafted to traverse outside plugins_dir.
|
||||
timeout: Installation timeout in seconds
|
||||
|
||||
Returns:
|
||||
@@ -160,59 +254,42 @@ class PluginLoader:
|
||||
|
||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||
requested_name = os.path.basename(plugin_dir_real)
|
||||
|
||||
if plugins_dir is not None:
|
||||
# Reconstruct the plugin path from a trusted base + a sanitised
|
||||
# directory name. os.path.basename() is CodeQL's recognised
|
||||
# py/path-injection sanitiser: it strips all directory components
|
||||
# so the result cannot contain traversal sequences. Joining it
|
||||
# with the resolved, trusted plugins_dir produces a path that
|
||||
# CodeQL considers untainted.
|
||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||
safe_dir_name = os.path.basename(plugin_dir_real)
|
||||
if not safe_dir_name:
|
||||
self.logger.error("Could not determine plugin directory name for %s", plugin_id)
|
||||
return False
|
||||
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name)
|
||||
if not os.path.isdir(safe_plugin_dir):
|
||||
self.logger.error(
|
||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||
)
|
||||
return False
|
||||
else:
|
||||
safe_plugin_dir = plugin_dir_real
|
||||
if not os.path.isdir(safe_plugin_dir):
|
||||
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
||||
return False
|
||||
# Match the requested directory against an entry actually enumerated
|
||||
# from the trusted plugins_dir, and build the path from that entry --
|
||||
# not from requested_name. A name that came out of os.scandir() on a
|
||||
# trusted root carries no taint regardless of what the caller asked
|
||||
# for, so this is a real containment guarantee (an allowlist check
|
||||
# against a trusted source), not a string-sanitisation of untrusted
|
||||
# input that a static analyzer has to trust blindly.
|
||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
||||
if matched_name is None:
|
||||
self.logger.error(
|
||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||
)
|
||||
return False
|
||||
|
||||
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
|
||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
||||
marker_file = os.path.join(safe_plugin_dir, ".dependencies_installed")
|
||||
|
||||
if not os.path.isfile(requirements_file):
|
||||
return True # No dependencies needed
|
||||
|
||||
try:
|
||||
with open(requirements_file, 'rb') as fh:
|
||||
current_hash = hashlib.sha256(fh.read()).hexdigest()
|
||||
except OSError as e:
|
||||
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
|
||||
return False
|
||||
if not requirements_has_real_deps(requirements_file):
|
||||
self.logger.debug(
|
||||
"requirements.txt for %s has no real dependencies (comments/blank only), skipping pip",
|
||||
plugin_id
|
||||
)
|
||||
return True
|
||||
|
||||
# Skip if requirements.txt hasn't changed since last install
|
||||
if os.path.isfile(marker_file):
|
||||
try:
|
||||
with open(marker_file, 'r', encoding='utf-8') as fh:
|
||||
stored_hash = fh.read().strip()
|
||||
except OSError as e:
|
||||
self.logger.warning(
|
||||
"Could not read dependency marker for %s (%s), will reinstall dependencies",
|
||||
plugin_id, e
|
||||
)
|
||||
else:
|
||||
if stored_hash == current_hash:
|
||||
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
|
||||
return True
|
||||
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
|
||||
if requirements_are_satisfied(requirements_file):
|
||||
self.logger.debug(
|
||||
"Dependencies for %s already satisfied in current environment, skipping pip",
|
||||
plugin_id
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||
@@ -225,32 +302,54 @@ class PluginLoader:
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
||||
fh.write(current_hash)
|
||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||
return True
|
||||
else:
|
||||
stderr = result.stderr or ""
|
||||
# uninstall-no-record-file means the package is already present at the
|
||||
# system level (e.g. installed via dnf/apt without a pip RECORD file).
|
||||
# pip can't replace it, but it IS installed — write the marker so we
|
||||
# don't retry on every restart.
|
||||
# uninstall-no-record-file means a system-managed copy of a package
|
||||
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in
|
||||
# the way of the version this requirements.txt pins. Retry with
|
||||
# --ignore-installed so pip lays the pinned version down alongside
|
||||
# the system copy instead of trying to replace it — matching the
|
||||
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
|
||||
# Without this retry, the plugin would silently keep running against
|
||||
# whatever version the system happened to ship.
|
||||
if "uninstall-no-record-file" in stderr:
|
||||
self.logger.warning(
|
||||
"Dependencies for %s include system-managed packages (no pip RECORD). "
|
||||
"Assuming they are satisfied: %s",
|
||||
"Dependencies for %s conflict with a system-managed package "
|
||||
"(no pip RECORD); retrying with --ignore-installed: %s",
|
||||
plugin_id, stderr.strip()
|
||||
)
|
||||
# Wrapped in its own try/except so a retry timeout is
|
||||
# tolerated the same way as a retry failure, instead of
|
||||
# propagating to the outer handler and returning False
|
||||
# (which would contradict the "assume satisfied" fallback
|
||||
# below).
|
||||
try:
|
||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
||||
fh.write(current_hash)
|
||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
||||
# sys.executable is this process's own interpreter (not
|
||||
# attacker-influenced), and requirements_file is a path
|
||||
# built internally by find_plugin_directory, never raw
|
||||
# external input.
|
||||
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
||||
"--ignore-installed", "-r", requirements_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
if retry_result.returncode != 0:
|
||||
self.logger.warning(
|
||||
"Retry with --ignore-installed also failed for %s; assuming the "
|
||||
"system-managed version satisfies the requirement: %s",
|
||||
plugin_id, (retry_result.stderr or "").strip()
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.warning(
|
||||
"Retry with --ignore-installed timed out for %s; assuming the "
|
||||
"system-managed version satisfies the requirement",
|
||||
plugin_id
|
||||
)
|
||||
return True
|
||||
self.logger.warning(
|
||||
"Dependency installation returned non-zero exit code for %s: %s",
|
||||
@@ -618,6 +717,14 @@ class PluginLoader:
|
||||
"""
|
||||
# Install dependencies if needed
|
||||
if install_deps:
|
||||
if plugins_dir is None:
|
||||
raise PluginError(
|
||||
f"plugins_dir is required to install dependencies for plugin {plugin_id} "
|
||||
"(needed for path containment; pass install_deps=False if the caller "
|
||||
"doesn't have a trusted plugins directory to supply)",
|
||||
plugin_id=plugin_id,
|
||||
context={'plugin_dir': str(plugin_dir)},
|
||||
)
|
||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
||||
raise PluginError(
|
||||
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
||||
|
||||
@@ -9,9 +9,9 @@ API Version: 1.0.0
|
||||
|
||||
import json
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
import logging
|
||||
@@ -177,90 +177,6 @@ class PluginManager:
|
||||
|
||||
return plugin_ids
|
||||
|
||||
def _get_dependency_marker_path(self, plugin_id: str) -> Path:
|
||||
"""Get path to dependency installation marker file."""
|
||||
plugin_dir = self.plugins_dir / plugin_id
|
||||
if not plugin_dir.exists():
|
||||
# Try with ledmatrix- prefix
|
||||
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
|
||||
return plugin_dir / ".dependencies_installed"
|
||||
|
||||
def _check_dependencies_installed(self, plugin_id: str) -> bool:
|
||||
"""Check if dependencies are already installed for a plugin."""
|
||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
||||
return marker_path.exists()
|
||||
|
||||
def _mark_dependencies_installed(self, plugin_id: str) -> None:
|
||||
"""Mark dependencies as installed for a plugin."""
|
||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
||||
try:
|
||||
marker_path.touch()
|
||||
# Set proper file permissions after creating marker
|
||||
from src.common.permission_utils import (
|
||||
ensure_file_permissions,
|
||||
get_plugin_file_mode
|
||||
)
|
||||
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||
except (OSError, PermissionError) as e:
|
||||
self.logger.warning("Could not create dependency marker for %s: %s", plugin_id, e)
|
||||
|
||||
def _remove_dependency_marker(self, plugin_id: str) -> None:
|
||||
"""Remove dependency installation marker."""
|
||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
||||
try:
|
||||
if marker_path.exists():
|
||||
marker_path.unlink()
|
||||
except (OSError, PermissionError) as e:
|
||||
self.logger.warning("Could not remove dependency marker for %s: %s", plugin_id, e)
|
||||
|
||||
def _install_plugin_dependencies(self, requirements_file: Path) -> bool:
|
||||
"""
|
||||
Install plugin dependencies from requirements.txt.
|
||||
|
||||
Args:
|
||||
requirements_file: Path to requirements.txt
|
||||
|
||||
Returns:
|
||||
True if installation succeeded or not needed, False on error
|
||||
"""
|
||||
try:
|
||||
self.logger.info("Installing dependencies from %s", requirements_file)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--no-cache-dir", "-r", str(requirements_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.info("Dependencies installed successfully")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning("Dependency installation returned non-zero exit code: %s", result.stderr)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("Dependency installation timed out")
|
||||
return False
|
||||
except FileNotFoundError as e:
|
||||
self.logger.warning("Command not found: %s. Skipping dependency installation", e)
|
||||
return True
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
||||
# Often caused by network interruptions or output buffer issues
|
||||
if isinstance(e, OSError) and e.errno == 32:
|
||||
self.logger.error(
|
||||
"Broken pipe error during dependency installation. "
|
||||
"This usually indicates a network interruption or pip output buffer issue. "
|
||||
"Try installing again or check your network connection."
|
||||
)
|
||||
else:
|
||||
self.logger.error("OS error during dependency installation: %s", e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Unexpected error installing dependencies: %s", e, exc_info=True)
|
||||
return True
|
||||
|
||||
def load_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Load a plugin by ID.
|
||||
@@ -390,7 +306,15 @@ class PluginManager:
|
||||
self.logger.error("Error validating plugin %s config: %s", plugin_id, e, exc_info=True)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Schema validation (warn/degrade only — never blocks loading).
|
||||
# A config that violates the plugin's JSON schema is surfaced to the
|
||||
# user (log warning + degraded flag in the health tracker) but the
|
||||
# plugin still loads exactly as it does today. This deliberately does
|
||||
# NOT change load_plugin()'s pass/fail behaviour for any plugin that
|
||||
# loads under the current code.
|
||||
self._validate_config_schema_soft(plugin_id, config)
|
||||
|
||||
# Store plugin instance
|
||||
self.plugins[plugin_id] = plugin_instance
|
||||
self.plugin_last_update[plugin_id] = 0.0
|
||||
@@ -419,6 +343,59 @@ class PluginManager:
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
|
||||
def _validate_config_schema_soft(self, plugin_id: str, config: Dict[str, Any]) -> None:
|
||||
"""Validate a plugin's config against its JSON schema — warn/degrade only.
|
||||
|
||||
On a schema violation this logs a warning and marks the plugin degraded
|
||||
in the health tracker (when one is wired), so the problem is visible in
|
||||
the web UI. It never raises, never changes plugin state, and never
|
||||
affects whether the plugin loads. ``config`` here has already been
|
||||
merged with schema defaults by the caller, so fields that ship a default
|
||||
never appear "missing" — only genuinely user-supplied required fields
|
||||
(e.g. an API key) can trip the required-field check.
|
||||
"""
|
||||
try:
|
||||
schema = self.schema_manager.load_schema(plugin_id)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
self.logger.debug("Could not load schema for %s: %s", plugin_id, e)
|
||||
return
|
||||
|
||||
if not schema:
|
||||
# No schema shipped — nothing to validate. Clear any stale flag.
|
||||
self._set_degraded_safe(plugin_id, None)
|
||||
return
|
||||
|
||||
try:
|
||||
is_valid, errors = self.schema_manager.validate_config_against_schema(
|
||||
config, schema, plugin_id
|
||||
)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
# Validation machinery itself failed — do not penalise the plugin.
|
||||
self.logger.debug("Schema validation raised for %s: %s", plugin_id, e)
|
||||
return
|
||||
|
||||
if is_valid or not errors:
|
||||
self._set_degraded_safe(plugin_id, None)
|
||||
return
|
||||
|
||||
summary = "; ".join(errors[:5])
|
||||
if len(errors) > 5:
|
||||
summary += f" (+{len(errors) - 5} more)"
|
||||
self.logger.warning(
|
||||
"Plugin %s config does not match its schema (loading anyway): %s",
|
||||
plugin_id, summary,
|
||||
)
|
||||
self._set_degraded_safe(plugin_id, f"Config schema: {summary}")
|
||||
|
||||
def _set_degraded_safe(self, plugin_id: str, reason: Optional[str]) -> None:
|
||||
"""Best-effort ``health_tracker.set_degraded`` that never raises."""
|
||||
if not self.health_tracker:
|
||||
return
|
||||
try:
|
||||
self.health_tracker.set_degraded(plugin_id, reason)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
self.logger.debug("Could not set degraded flag for %s: %s", plugin_id, e)
|
||||
|
||||
def unload_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Unload a plugin by ID.
|
||||
@@ -767,8 +744,18 @@ class PluginManager:
|
||||
# If resource monitor exists, wrap the call
|
||||
def monitored_update():
|
||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||
# SimpleNamespace stores `update` as an *instance*
|
||||
# attribute, so attribute lookup returns the plain
|
||||
# function object as-is. A dynamically-built class
|
||||
# (`type(..., {'update': monitored_update})`) instead
|
||||
# stores it as a *class* attribute, which the
|
||||
# descriptor protocol turns into a bound method on
|
||||
# access -- silently prepending the instance as an
|
||||
# implicit first argument to a function that takes
|
||||
# none, raising "monitored_update() takes 0
|
||||
# positional arguments but 1 was given" on every call.
|
||||
success = self.plugin_executor.execute_update(
|
||||
type('obj', (object,), {'update': monitored_update})(),
|
||||
types.SimpleNamespace(update=monitored_update),
|
||||
plugin_id
|
||||
)
|
||||
else:
|
||||
@@ -836,7 +823,7 @@ class PluginManager:
|
||||
|
||||
# Get health tracker metrics if available
|
||||
if self.health_tracker:
|
||||
health_info = self.health_tracker.get_plugin_health(plugin_id)
|
||||
health_info = self.health_tracker.get_health_summary(plugin_id)
|
||||
plugin_metrics['health'] = health_info
|
||||
else:
|
||||
plugin_metrics['health'] = {'status': 'unknown'}
|
||||
@@ -861,7 +848,7 @@ class PluginManager:
|
||||
|
||||
# Get resource monitor metrics if available
|
||||
if self.resource_monitor:
|
||||
resource_info = self.resource_monitor.get_plugin_metrics(plugin_id)
|
||||
resource_info = self.resource_monitor.get_metrics_summary(plugin_id)
|
||||
plugin_metrics['resources'] = resource_info
|
||||
else:
|
||||
plugin_metrics['resources'] = {'status': 'unknown'}
|
||||
|
||||
@@ -71,17 +71,32 @@ class PluginResourceMonitor:
|
||||
self.cache_manager = cache_manager
|
||||
self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Resource metrics per plugin
|
||||
self._metrics: Dict[str, ResourceMetrics] = {}
|
||||
self._limits: Dict[str, ResourceLimits] = {}
|
||||
|
||||
|
||||
# Thread-local storage for execution tracking
|
||||
self._local = threading.local()
|
||||
|
||||
|
||||
# Lock for thread-safe access
|
||||
self._lock = threading.Lock()
|
||||
|
||||
|
||||
# Cache a single psutil.Process handle. Reusing the same handle is what
|
||||
# lets cpu_percent() be read non-blocking (interval=None): psutil returns
|
||||
# the utilisation since the *previous* call on that same object. Creating
|
||||
# a fresh Process() per call would force interval-based sampling that
|
||||
# blocks the caller — unacceptable on the display loop's update path.
|
||||
self._process = None
|
||||
if self.enable_monitoring:
|
||||
try:
|
||||
self._process = psutil.Process()
|
||||
# Prime cpu_percent so the first real measurement returns a
|
||||
# meaningful delta instead of 0.0.
|
||||
self._process.cpu_percent(interval=None)
|
||||
except Exception: # pragma: no cover - psutil edge cases
|
||||
self._process = None
|
||||
|
||||
if not PSUTIL_AVAILABLE and enable_monitoring:
|
||||
self.logger.warning(
|
||||
"psutil not available - resource monitoring will be limited to execution time only"
|
||||
@@ -95,13 +110,21 @@ class PluginResourceMonitor:
|
||||
"""Get cache key for plugin limits."""
|
||||
return f"plugin_limits:{plugin_id}"
|
||||
|
||||
def get_metrics(self, plugin_id: str) -> ResourceMetrics:
|
||||
"""Get current metrics for a plugin."""
|
||||
def get_metrics(self, plugin_id: str, force_reload: bool = False) -> ResourceMetrics:
|
||||
"""Get current metrics for a plugin.
|
||||
|
||||
``force_reload=True`` bypasses both the in-memory copy and the cache
|
||||
manager's memory tier so a read-only consumer (e.g. the web process)
|
||||
sees the writer process's latest persisted metrics rather than a stale
|
||||
first snapshot.
|
||||
"""
|
||||
with self._lock:
|
||||
if plugin_id not in self._metrics:
|
||||
if force_reload or plugin_id not in self._metrics:
|
||||
# Try to load from cache
|
||||
cache_key = self._get_metrics_key(plugin_id)
|
||||
cached = self.cache_manager.get(cache_key, max_age=None)
|
||||
cached = self.cache_manager.get(
|
||||
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
||||
)
|
||||
if cached:
|
||||
metrics = ResourceMetrics(**cached)
|
||||
else:
|
||||
@@ -137,21 +160,24 @@ class PluginResourceMonitor:
|
||||
|
||||
def _get_process_memory_mb(self) -> float:
|
||||
"""Get current process memory usage in MB."""
|
||||
if not self.enable_monitoring:
|
||||
if not self.enable_monitoring or self._process is None:
|
||||
return 0.0
|
||||
try:
|
||||
process = psutil.Process()
|
||||
return process.memory_info().rss / 1024 / 1024
|
||||
return self._process.memory_info().rss / 1024 / 1024
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _get_process_cpu_percent(self, interval: float = 0.1) -> float:
|
||||
"""Get current process CPU usage percentage."""
|
||||
if not self.enable_monitoring:
|
||||
|
||||
def _get_process_cpu_percent(self) -> float:
|
||||
"""Get current process CPU usage percentage (non-blocking).
|
||||
|
||||
Reads cpu_percent(interval=None) against the cached process handle, so
|
||||
it returns immediately with the utilisation observed since the previous
|
||||
call rather than blocking to sample a fresh interval.
|
||||
"""
|
||||
if not self.enable_monitoring or self._process is None:
|
||||
return 0.0
|
||||
try:
|
||||
process = psutil.Process()
|
||||
return process.cpu_percent(interval=interval)
|
||||
return self._process.cpu_percent(interval=None)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@@ -281,9 +307,13 @@ class PluginResourceMonitor:
|
||||
self.logger.error(error_msg)
|
||||
raise ResourceLimitExceeded(error_msg)
|
||||
|
||||
def get_metrics_summary(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Get metrics summary for a plugin."""
|
||||
metrics = self.get_metrics(plugin_id)
|
||||
def get_metrics_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||
"""Get metrics summary for a plugin.
|
||||
|
||||
``force_reload=True`` refreshes from the persisted cache first so
|
||||
cross-process readers reflect the writer's latest metrics.
|
||||
"""
|
||||
metrics = self.get_metrics(plugin_id, force_reload=force_reload)
|
||||
limits = self.get_limits(plugin_id)
|
||||
|
||||
avg_execution_time = 0.0
|
||||
|
||||
@@ -5,7 +5,6 @@ Handles plugin discovery, installation, updates, and uninstallation
|
||||
from both the official registry and custom GitHub repositories.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
@@ -26,6 +25,9 @@ import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from src.common.permission_utils import sudo_remove_directory, install_requirements_file
|
||||
from src.plugin_system.plugin_loader import (
|
||||
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
|
||||
)
|
||||
|
||||
try:
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
@@ -1900,19 +1902,45 @@ class PluginStoreManager:
|
||||
def _install_dependencies(self, plugin_path: Path) -> bool:
|
||||
"""
|
||||
Install Python dependencies from requirements.txt.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_path: Path to plugin directory
|
||||
|
||||
|
||||
Returns:
|
||||
True if successful or no requirements file
|
||||
"""
|
||||
requirements_file = plugin_path / "requirements.txt"
|
||||
|
||||
# Reconstruct the plugin path from the trusted self.plugins_dir base +
|
||||
# an entry actually enumerated from it, rather than trusting
|
||||
# plugin_path directly -- callers ultimately derive it from a
|
||||
# plugin-supplied manifest "id" field (see install_plugin_from_url),
|
||||
# so without this a malicious manifest could point requirements_file
|
||||
# outside plugins_dir. find_trusted_subdir()'s return value always
|
||||
# comes from os.scandir() on the trusted root, so building the path
|
||||
# from it (not from the caller's string) is a real containment
|
||||
# guarantee, matching the pattern in PluginLoader.install_dependencies().
|
||||
plugin_dir_real = os.path.realpath(str(plugin_path))
|
||||
plugins_dir_real = os.path.realpath(str(self.plugins_dir))
|
||||
requested_name = os.path.basename(plugin_dir_real)
|
||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
||||
if matched_name is None:
|
||||
self.logger.error("Plugin directory not found inside plugins dir for dependency install")
|
||||
return False
|
||||
safe_plugin_path = Path(os.path.join(plugins_dir_real, matched_name))
|
||||
|
||||
requirements_file = safe_plugin_path / "requirements.txt"
|
||||
|
||||
if not requirements_file.exists():
|
||||
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
|
||||
return True
|
||||
|
||||
|
||||
if not requirements_has_real_deps(str(requirements_file)):
|
||||
self.logger.debug(f"requirements.txt for {plugin_path.name} has no real dependencies, skipping pip")
|
||||
return True
|
||||
|
||||
if requirements_are_satisfied(str(requirements_file)):
|
||||
self.logger.debug(f"Dependencies for {plugin_path.name} already satisfied, skipping pip")
|
||||
return True
|
||||
|
||||
try:
|
||||
self.logger.info(f"Installing dependencies for {plugin_path.name}")
|
||||
# Routed through the shared root-visible installer (same one the
|
||||
@@ -1929,12 +1957,6 @@ class PluginStoreManager:
|
||||
)
|
||||
return False
|
||||
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
||||
# Write hash marker so plugin_loader skips redundant pip run on next startup
|
||||
try:
|
||||
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
|
||||
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
|
||||
return True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -2432,19 +2454,6 @@ class PluginStoreManager:
|
||||
file_path = line[3:].strip()
|
||||
untracked_files.append(file_path)
|
||||
|
||||
# Remove marker files that are safe to delete (they'll be regenerated)
|
||||
safe_to_remove = ['.dependencies_installed']
|
||||
removed_files = []
|
||||
for file_name in safe_to_remove:
|
||||
file_path = plugin_path / file_name
|
||||
if file_path.exists() and file_name in untracked_files:
|
||||
try:
|
||||
file_path.unlink()
|
||||
removed_files.append(file_name)
|
||||
self.logger.info(f"Removed marker file {file_name} from {plugin_id} before update")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not remove {file_name} from {plugin_id}: {e}")
|
||||
|
||||
# Check for tracked file changes
|
||||
status_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
|
||||
@@ -2455,10 +2464,9 @@ class PluginStoreManager:
|
||||
)
|
||||
has_changes = bool(status_result.stdout.strip())
|
||||
|
||||
# If there are remaining untracked files (not safe to remove), stash them
|
||||
remaining_untracked = [f for f in untracked_files if f not in removed_files]
|
||||
if remaining_untracked:
|
||||
self.logger.info(f"Found {len(remaining_untracked)} untracked files in {plugin_id}, will stash them")
|
||||
# If there are untracked files, stash them
|
||||
if untracked_files:
|
||||
self.logger.info(f"Found {len(untracked_files)} untracked files in {plugin_id}, will stash them")
|
||||
has_changes = True
|
||||
except subprocess.TimeoutExpired:
|
||||
# If status check times out, assume there might be changes and proceed
|
||||
|
||||
@@ -454,6 +454,18 @@ class VisualTestDisplayManager:
|
||||
"""Check if display is currently scrolling."""
|
||||
return self._scrolling_state['is_scrolling']
|
||||
|
||||
def process_deferred_updates(self):
|
||||
"""Process any deferred updates (no-op for testing).
|
||||
|
||||
Several ticker-style plugins (news, odds-ticker, leaderboard,
|
||||
stock-news, stocks) call this unconditionally between
|
||||
set_scrolling_state() and their scroll-position update, mirroring the
|
||||
real display_manager's deferred-update queue. This double has no such
|
||||
queue, so there is nothing to process — the no-op just lets those
|
||||
plugins render under the harness instead of raising AttributeError.
|
||||
"""
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utility methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
+90
-1
@@ -1883,7 +1883,96 @@ class WiFiManager:
|
||||
|
||||
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
||||
return False
|
||||
|
||||
|
||||
def get_wifi_radio_state(self) -> Dict:
|
||||
"""
|
||||
Report whether the WiFi radio is currently enabled, plus whether a wired
|
||||
fallback exists. Used by the web UI's radio toggle so it can warn before
|
||||
an action that could disconnect the browser.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'enabled': Optional[bool], # True/False, or None if undeterminable
|
||||
'ethernet_connected': bool, # wired fallback present
|
||||
'available': bool, # nmcli present / radio state readable
|
||||
}
|
||||
"""
|
||||
ethernet_connected = self._is_ethernet_connected()
|
||||
enabled: Optional[bool] = None
|
||||
available = False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["nmcli", "radio", "wifi"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
status = result.stdout.strip().lower()
|
||||
if status in ("enabled", "disabled"):
|
||||
enabled = status == "enabled"
|
||||
available = True
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read WiFi radio state: {e}")
|
||||
return {
|
||||
'enabled': enabled,
|
||||
'ethernet_connected': ethernet_connected,
|
||||
'available': available,
|
||||
}
|
||||
|
||||
def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Turn the WiFi radio on or off.
|
||||
|
||||
Turning the radio OFF from the web interface is dangerous: if the device
|
||||
is reachable only over WiFi, disabling it disconnects the very page that
|
||||
issued the request. To prevent that lockout, disabling is refused unless a
|
||||
wired (Ethernet) fallback is present, or the caller explicitly passes
|
||||
force=True to acknowledge the risk.
|
||||
|
||||
Enabling reuses the hardened _ensure_wifi_radio_enabled() path (handles
|
||||
rfkill soft-blocks + retries). Both directions rely only on
|
||||
`nmcli radio wifi on|off`, which is already covered by the passwordless
|
||||
sudoers allowlist (configure_wifi_permissions.sh) — no new privileged
|
||||
command is introduced.
|
||||
|
||||
Returns:
|
||||
(success, human-readable message, reason_code). reason_code is
|
||||
'no_ethernet' when a disable is refused for lockout safety, or a
|
||||
short failure code otherwise; None on success. The web UI keys on
|
||||
'no_ethernet' to decide whether to offer a force-off prompt.
|
||||
"""
|
||||
if enabled:
|
||||
if self._ensure_wifi_radio_enabled():
|
||||
return True, "WiFi radio enabled.", None
|
||||
return False, "Failed to enable WiFi radio. Check logs for details.", 'enable_failed'
|
||||
|
||||
# Disabling — guard against locking the user out of the web interface.
|
||||
if not force and not self._is_ethernet_connected():
|
||||
return False, (
|
||||
"Refusing to disable WiFi: no wired (Ethernet) connection was "
|
||||
"detected, so turning off WiFi would disconnect you from this "
|
||||
"page. Connect Ethernet first, or force it if you're sure."
|
||||
), 'no_ethernet'
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "nmcli", "radio", "wifi", "off"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("WiFi radio disabled via web interface (force=%s)", force)
|
||||
return True, "WiFi radio disabled.", None
|
||||
logger.warning("Failed to disable WiFi radio: %s", result.stderr.strip())
|
||||
return False, "Failed to disable WiFi radio. Check logs for details.", 'command_failed'
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Command timed out while disabling WiFi radio.", 'timeout'
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.error("Error disabling WiFi radio: %s", e, exc_info=True)
|
||||
return False, "An error occurred while disabling WiFi radio.", 'error'
|
||||
|
||||
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Enable access point mode
|
||||
|
||||
@@ -172,6 +172,16 @@ class TestVisualDisplayManager:
|
||||
vdm.set_scrolling_state(False)
|
||||
assert vdm.is_currently_scrolling() is False
|
||||
|
||||
def test_process_deferred_updates_is_noop(self):
|
||||
# Ticker-style plugins (news, odds-ticker, leaderboard, stock-news,
|
||||
# stocks) call this unconditionally alongside set_scrolling_state();
|
||||
# it must exist and be harmless so those plugins render under the
|
||||
# harness instead of raising AttributeError.
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.set_scrolling_state(True)
|
||||
vdm.process_deferred_updates() # should not raise
|
||||
assert vdm.is_currently_scrolling() is True
|
||||
|
||||
def test_format_date_with_ordinal(self):
|
||||
from datetime import datetime
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
|
||||
@@ -279,10 +279,23 @@ class TestDiskCache:
|
||||
"""Test getting expired cache entry."""
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("test_key", {"data": "value"})
|
||||
|
||||
|
||||
# Get with max_age=0 to force expiration
|
||||
result = cache.get("test_key", max_age=0)
|
||||
assert result is None
|
||||
|
||||
def test_get_max_age_none_never_expires(self, tmp_path):
|
||||
"""max_age=None must return persisted records regardless of age.
|
||||
|
||||
Regression: the age comparison raised TypeError for max_age=None,
|
||||
which was swallowed and treated as a miss — silently breaking
|
||||
long-lived state (plugin health/metrics) read across processes.
|
||||
"""
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("test_key", {"data": "value", "timestamp": 0}) # epoch → very old
|
||||
result = cache.get("test_key", max_age=None)
|
||||
assert result is not None
|
||||
assert result["data"] == "value"
|
||||
|
||||
def test_get_nonexistent(self, tmp_path):
|
||||
"""Test getting non-existent key."""
|
||||
|
||||
@@ -391,3 +391,16 @@ class TestDisplayControllerSchedule:
|
||||
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is False
|
||||
|
||||
|
||||
class TestPluginHealthWiring:
|
||||
"""Phase 1: DisplayController activates the dormant plugin health/metrics
|
||||
subsystem by wiring real tracker/monitor instances onto the plugin manager."""
|
||||
|
||||
def test_health_tracker_and_resource_monitor_wired(self, test_display_controller):
|
||||
from src.plugin_system.plugin_health import PluginHealthTracker
|
||||
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||
|
||||
pm = test_display_controller.plugin_manager
|
||||
assert isinstance(pm.health_tracker, PluginHealthTracker)
|
||||
assert isinstance(pm.resource_monitor, PluginResourceMonitor)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Tests for src.common.permission_utils's URL-credential redaction.
|
||||
|
||||
Covers the fix for a CodeQL clear-text-logging-of-secrets alert:
|
||||
install_requirements_file() must never let a private index URL's embedded
|
||||
user:pass@ credentials reach logs or its returned CompletedProcess, since
|
||||
pip can echo that URL back verbatim in its own stderr/stdout on failure.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.common.permission_utils import _redact_url_credentials, install_requirements_file
|
||||
|
||||
|
||||
class TestRedactUrlCredentials:
|
||||
def test_redacts_embedded_basic_auth(self):
|
||||
text = "Could not fetch URL https://alice:s3cr3t@pypi.example.com/simple/: 403"
|
||||
redacted = _redact_url_credentials(text)
|
||||
assert "s3cr3t" not in redacted
|
||||
assert "alice" not in redacted
|
||||
assert "https://***:***@pypi.example.com/simple/" in redacted
|
||||
|
||||
def test_leaves_credential_free_text_unchanged(self):
|
||||
text = "ERROR: Could not find a version that satisfies the requirement foo==1.0"
|
||||
assert _redact_url_credentials(text) == text
|
||||
|
||||
def test_handles_none_and_empty(self):
|
||||
assert _redact_url_credentials(None) == ""
|
||||
assert _redact_url_credentials("") == ""
|
||||
|
||||
def test_does_not_touch_denied_check_phrases(self):
|
||||
"""The fixed phrases install_requirements_file greps for must survive
|
||||
redaction untouched -- they don't overlap with URL syntax, but this
|
||||
pins that assumption so a regex change can't silently break it."""
|
||||
text = "sudo: a password is required"
|
||||
assert _redact_url_credentials(text) == text
|
||||
|
||||
|
||||
class TestInstallRequirementsFileRedaction:
|
||||
@patch('src.common.permission_utils.subprocess.run')
|
||||
def test_wrapper_path_redacts_stderr_and_stdout(self, mock_run, tmp_path):
|
||||
"""safe_pip_install.sh exists in this repo, so install_requirements_file
|
||||
takes the sudo-wrapper branch; a failing result must come back
|
||||
with any embedded index-URL credentials already redacted."""
|
||||
req_file = tmp_path / "requirements.txt"
|
||||
req_file.write_text("requests\n")
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout="Looking in indexes: https://bob:hunter2@pypi.internal/simple\n",
|
||||
stderr="ERROR https://bob:hunter2@pypi.internal/simple/foo: 401",
|
||||
)
|
||||
|
||||
result = install_requirements_file(req_file, timeout=5)
|
||||
|
||||
assert "hunter2" not in result.stdout
|
||||
assert "hunter2" not in result.stderr
|
||||
assert "https://***:***@pypi.internal" in result.stdout
|
||||
assert "https://***:***@pypi.internal" in result.stderr
|
||||
|
||||
@patch('src.common.permission_utils.subprocess.run')
|
||||
@patch('src.common.permission_utils.Path.exists', return_value=False)
|
||||
def test_no_wrapper_fallback_path_redacts_stderr_and_stdout(self, mock_exists, mock_run, tmp_path):
|
||||
"""No safe_pip_install.sh wrapper -> falls straight to the
|
||||
sys.executable pip fallback (the second subprocess.run call site);
|
||||
its result must come back redacted too, independent of the wrapper
|
||||
branch's own redaction above."""
|
||||
req_file = tmp_path / "requirements.txt"
|
||||
req_file.write_text("requests\n")
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout="Looking in indexes: https://carol:swordfish@pypi.internal/simple\n",
|
||||
stderr="ERROR https://carol:swordfish@pypi.internal/simple/foo: 401",
|
||||
)
|
||||
|
||||
result = install_requirements_file(req_file, timeout=5)
|
||||
|
||||
assert "swordfish" not in result.stdout
|
||||
assert "swordfish" not in result.stderr
|
||||
assert "https://***:***@pypi.internal" in result.stdout
|
||||
assert "https://***:***@pypi.internal" in result.stderr
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Tests for src/plugin_system/plugin_health.py
|
||||
|
||||
Focus on the additive ``set_degraded`` mechanism used by the warn-only schema
|
||||
validation path: it must surface a degraded reason without touching the circuit
|
||||
breaker or causing the plugin to be skipped.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.plugin_system.plugin_health import PluginHealthTracker, CircuitState
|
||||
|
||||
|
||||
def _cache():
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
return cache
|
||||
|
||||
|
||||
def test_set_degraded_marks_and_surfaces_reason():
|
||||
tracker = PluginHealthTracker(_cache())
|
||||
tracker.set_degraded("p", "bad config")
|
||||
summary = tracker.get_health_summary("p")
|
||||
assert summary["degraded"] is True
|
||||
assert summary["degraded_reason"] == "bad config"
|
||||
|
||||
|
||||
def test_set_degraded_none_clears():
|
||||
tracker = PluginHealthTracker(_cache())
|
||||
tracker.set_degraded("p", "bad config")
|
||||
tracker.set_degraded("p", None)
|
||||
summary = tracker.get_health_summary("p")
|
||||
assert summary["degraded"] is False
|
||||
assert summary["degraded_reason"] is None
|
||||
|
||||
|
||||
def test_set_degraded_does_not_affect_circuit_breaker():
|
||||
tracker = PluginHealthTracker(_cache())
|
||||
tracker.set_degraded("p", "bad config")
|
||||
summary = tracker.get_health_summary("p")
|
||||
# Degraded is a *separate* signal from circuit health: the plugin is not
|
||||
# counted as failing, the circuit stays closed, and it is not skipped.
|
||||
assert summary["circuit_state"] == CircuitState.CLOSED.value
|
||||
assert summary["consecutive_failures"] == 0
|
||||
assert summary["is_healthy"] is True
|
||||
assert tracker.should_skip_plugin("p") is False
|
||||
|
||||
|
||||
def test_set_degraded_skips_redundant_cache_write():
|
||||
cache = _cache()
|
||||
tracker = PluginHealthTracker(cache)
|
||||
tracker.set_degraded("p", "x")
|
||||
writes_after_first = cache.set.call_count
|
||||
assert writes_after_first >= 1
|
||||
tracker.set_degraded("p", "x") # unchanged → no extra write
|
||||
assert cache.set.call_count == writes_after_first
|
||||
|
||||
|
||||
def test_default_summary_has_degraded_fields():
|
||||
tracker = PluginHealthTracker(_cache())
|
||||
summary = tracker.get_health_summary("never-seen")
|
||||
assert summary["degraded"] is False
|
||||
assert summary["degraded_reason"] is None
|
||||
|
||||
|
||||
def test_force_reload_refreshes_stale_in_memory_snapshot():
|
||||
"""A long-lived reader (e.g. the web process) must not be pinned to the
|
||||
first snapshot: force_reload re-reads persisted state and bypasses the
|
||||
cache manager's memory tier so cross-process updates are visible."""
|
||||
cache = _cache()
|
||||
tracker = PluginHealthTracker(cache)
|
||||
|
||||
# First read snapshots an empty (healthy) state into the in-memory copy.
|
||||
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
|
||||
|
||||
# The display service later persists a failing/open state.
|
||||
cache.get.return_value = {
|
||||
"consecutive_failures": 5,
|
||||
"circuit_state": "open",
|
||||
"total_failures": 5,
|
||||
"total_successes": 0,
|
||||
}
|
||||
|
||||
# A plain read is still pinned to the stale snapshot...
|
||||
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
|
||||
|
||||
# ...but force_reload observes the new persisted state.
|
||||
fresh = tracker.get_health_summary("p", force_reload=True)
|
||||
assert fresh["consecutive_failures"] == 5
|
||||
assert fresh["circuit_state"] == "open"
|
||||
|
||||
# and it asked the cache to bypass the in-memory tier (memory_ttl=0).
|
||||
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
|
||||
+128
-6
@@ -4,6 +4,8 @@ Tests for PluginLoader.
|
||||
Tests plugin directory discovery, module loading, and class instantiation.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
@@ -191,7 +193,7 @@ class TestPluginLoader:
|
||||
|
||||
mock_subprocess.return_value = MagicMock(returncode=0)
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
mock_subprocess.assert_called_once()
|
||||
@@ -202,7 +204,7 @@ class TestPluginLoader:
|
||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||
plugin_dir.mkdir()
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
mock_subprocess.assert_not_called()
|
||||
@@ -214,9 +216,129 @@ class TestPluginLoader:
|
||||
plugin_dir.mkdir()
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
requirements_file.write_text("package1==1.0.0\n")
|
||||
|
||||
|
||||
mock_subprocess.return_value = MagicMock(returncode=1)
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is False
|
||||
|
||||
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
|
||||
@patch('subprocess.run')
|
||||
def test_install_dependencies_retries_with_ignore_installed_on_apt_conflict(
|
||||
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
|
||||
):
|
||||
"""An apt-managed package with no pip RECORD file triggers a retry with
|
||||
--ignore-installed rather than silently assuming the old version satisfies
|
||||
the requirement. requirements_are_satisfied() is mocked False here because
|
||||
this scenario is exactly the case where the installed (apt) version does
|
||||
NOT satisfy the pin — that's why pip attempts a reinstall in the first place."""
|
||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||
plugin_dir.mkdir()
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
|
||||
|
||||
first_attempt = MagicMock(
|
||||
returncode=1,
|
||||
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
|
||||
)
|
||||
retry_attempt = MagicMock(returncode=0, stderr="")
|
||||
mock_subprocess.side_effect = [first_attempt, retry_attempt]
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
assert mock_subprocess.call_count == 2
|
||||
retry_cmd = mock_subprocess.call_args_list[1][0][0]
|
||||
assert "--ignore-installed" in retry_cmd
|
||||
|
||||
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
|
||||
@patch('subprocess.run')
|
||||
def test_install_dependencies_apt_conflict_retry_also_fails(
|
||||
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
|
||||
):
|
||||
"""Still tolerates the failure (returns True) if the --ignore-installed
|
||||
retry itself fails, matching the prior soft-fallback behavior."""
|
||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||
plugin_dir.mkdir()
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
|
||||
|
||||
first_attempt = MagicMock(
|
||||
returncode=1,
|
||||
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
|
||||
)
|
||||
retry_attempt = MagicMock(returncode=1, stderr="some other pip error")
|
||||
mock_subprocess.side_effect = [first_attempt, retry_attempt]
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
assert mock_subprocess.call_count == 2
|
||||
|
||||
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
|
||||
@patch('subprocess.run')
|
||||
def test_install_dependencies_apt_conflict_retry_times_out(
|
||||
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
|
||||
):
|
||||
"""A retry timeout must be tolerated the same way as a retry failure
|
||||
(return True), not propagate to the outer TimeoutExpired handler and
|
||||
return False."""
|
||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||
plugin_dir.mkdir()
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
|
||||
|
||||
first_attempt = MagicMock(
|
||||
returncode=1,
|
||||
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
|
||||
)
|
||||
mock_subprocess.side_effect = [
|
||||
first_attempt,
|
||||
subprocess.TimeoutExpired(cmd="pip", timeout=300),
|
||||
]
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
assert mock_subprocess.call_count == 2
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_install_dependencies_already_satisfied_skips_pip(self, mock_subprocess, plugin_loader, tmp_plugins_dir):
|
||||
"""A requirement already satisfied in the current environment shouldn't invoke pip."""
|
||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||
plugin_dir.mkdir()
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
requirements_file.write_text("pytest>=1.0\n")
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
mock_subprocess.assert_not_called()
|
||||
|
||||
def test_install_dependencies_requires_plugins_dir(self, plugin_loader, tmp_plugins_dir):
|
||||
"""plugins_dir is a required argument, not an optional trust-me flag --
|
||||
calling without it must fail loudly (TypeError) rather than silently
|
||||
falling back to trusting plugin_dir unchecked."""
|
||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||
plugin_dir.mkdir()
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_install_dependencies_rejects_path_outside_plugins_dir(
|
||||
self, mock_subprocess, plugin_loader, tmp_path, tmp_plugins_dir
|
||||
):
|
||||
"""A plugin_dir that doesn't actually live inside plugins_dir (e.g. a
|
||||
manifest-derived id crafted to traverse elsewhere) must be rejected
|
||||
rather than read from -- this is the path-injection containment
|
||||
check CodeQL flagged as missing."""
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "requirements.txt").write_text("requests>=2.0\n")
|
||||
|
||||
result = plugin_loader.install_dependencies(outside_dir, "evil_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is False
|
||||
mock_subprocess.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Tests for PluginManager._validate_config_schema_soft (Phase 1, warn-only schema
|
||||
validation).
|
||||
|
||||
Contract:
|
||||
- A schema violation logs a warning and marks the plugin degraded in the health
|
||||
tracker, but never raises and never changes load pass/fail behaviour.
|
||||
- A valid config (or no schema) clears any stale degraded flag.
|
||||
- The method is safe when no health tracker is wired.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pm():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manager = PluginManager(plugins_dir=str(Path(tmp) / "plugins"))
|
||||
manager.schema_manager = MagicMock()
|
||||
yield manager
|
||||
|
||||
|
||||
def test_invalid_config_marks_degraded_without_raising(pm):
|
||||
pm.health_tracker = MagicMock()
|
||||
pm.schema_manager.load_schema.return_value = {"type": "object"}
|
||||
pm.schema_manager.validate_config_against_schema.return_value = (
|
||||
False,
|
||||
["Missing required field: 'api_key'"],
|
||||
)
|
||||
|
||||
pm._validate_config_schema_soft("youtube-stats", {})
|
||||
|
||||
pm.health_tracker.set_degraded.assert_called_once()
|
||||
plugin_id, reason = pm.health_tracker.set_degraded.call_args[0]
|
||||
assert plugin_id == "youtube-stats"
|
||||
assert "api_key" in reason
|
||||
|
||||
|
||||
def test_valid_config_clears_degraded(pm):
|
||||
pm.health_tracker = MagicMock()
|
||||
pm.schema_manager.load_schema.return_value = {"type": "object"}
|
||||
pm.schema_manager.validate_config_against_schema.return_value = (True, [])
|
||||
|
||||
pm._validate_config_schema_soft("p", {"api_key": "x"})
|
||||
|
||||
pm.health_tracker.set_degraded.assert_called_once_with("p", None)
|
||||
|
||||
|
||||
def test_no_schema_clears_degraded(pm):
|
||||
pm.health_tracker = MagicMock()
|
||||
pm.schema_manager.load_schema.return_value = None
|
||||
|
||||
pm._validate_config_schema_soft("p", {})
|
||||
|
||||
pm.health_tracker.set_degraded.assert_called_once_with("p", None)
|
||||
|
||||
|
||||
def test_validation_exception_is_swallowed(pm):
|
||||
pm.health_tracker = MagicMock()
|
||||
pm.schema_manager.load_schema.return_value = {"type": "object"}
|
||||
pm.schema_manager.validate_config_against_schema.side_effect = RuntimeError("boom")
|
||||
|
||||
# Must not raise — the validation machinery failing must never break loading.
|
||||
pm._validate_config_schema_soft("p", {})
|
||||
|
||||
|
||||
def test_safe_without_health_tracker(pm):
|
||||
pm.health_tracker = None
|
||||
pm.schema_manager.load_schema.return_value = {"type": "object"}
|
||||
pm.schema_manager.validate_config_against_schema.return_value = (False, ["err"])
|
||||
|
||||
# Must not raise even though there is no tracker to record against.
|
||||
pm._validate_config_schema_soft("p", {})
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||
from pathlib import Path
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.plugin_system.plugin_state import PluginState
|
||||
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||
|
||||
class TestPluginManager:
|
||||
"""Test PluginManager functionality."""
|
||||
@@ -74,18 +75,67 @@ class TestPluginManager:
|
||||
|
||||
# No manifest in pm.plugin_manifests
|
||||
result = pm.load_plugin("non_existent_plugin")
|
||||
|
||||
|
||||
assert result is False
|
||||
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
|
||||
|
||||
def test_run_scheduled_updates_calls_update_with_resource_monitor(
|
||||
self, mock_config_manager, mock_display_manager, mock_cache_manager
|
||||
):
|
||||
"""Regression test: run_scheduled_updates() must actually call a
|
||||
plugin's update() when self.resource_monitor is set (as it is in
|
||||
every real deployment -- display_controller.py and web_interface/
|
||||
app.py both assign a real PluginResourceMonitor after construction).
|
||||
|
||||
Previously, the resource_monitor branch wrapped the call in a
|
||||
function stored as a *class* attribute on a dynamically-built type
|
||||
(`type('obj', (object,), {'update': monitored_update})()`), which
|
||||
the descriptor protocol turns into a bound method on access --
|
||||
silently passing the synthetic instance as an implicit first
|
||||
argument to monitored_update(), which takes none. Every plugin's
|
||||
scheduled update failed with "monitored_update() takes 0 positional
|
||||
arguments but 1 was given" and was silently swallowed into a
|
||||
circuit-breaker retry loop that never succeeded, so plugin data
|
||||
(scores, odds, etc.) never refreshed.
|
||||
"""
|
||||
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'):
|
||||
pm = PluginManager(
|
||||
plugins_dir="plugins",
|
||||
config_manager=mock_config_manager,
|
||||
display_manager=mock_display_manager,
|
||||
cache_manager=mock_cache_manager
|
||||
)
|
||||
|
||||
plugin_instance = MagicMock()
|
||||
plugin_instance.enabled = True
|
||||
plugin_instance.update = MagicMock()
|
||||
|
||||
pm.plugins["test_plugin"] = plugin_instance
|
||||
pm.plugin_manifests["test_plugin"] = {"update_interval": 10}
|
||||
pm.state_manager.set_state("test_plugin", PluginState.ENABLED)
|
||||
# Plain MagicMock, not the mock_cache_manager fixture: this test
|
||||
# is about run_scheduled_updates() actually invoking update()
|
||||
# through the resource-monitor wrapper, not about
|
||||
# PluginResourceMonitor's own cache-backed metrics persistence
|
||||
# (which calls cache_manager.get(..., memory_ttl=...) --
|
||||
# a kwarg the fixture's mock_get() doesn't accept).
|
||||
pm.resource_monitor = PluginResourceMonitor(MagicMock())
|
||||
|
||||
pm.run_scheduled_updates(current_time=time.time())
|
||||
|
||||
plugin_instance.update.assert_called_once()
|
||||
assert "test_plugin" in pm.plugin_last_update
|
||||
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
|
||||
|
||||
|
||||
class TestPluginLoader:
|
||||
"""Test PluginLoader functionality."""
|
||||
|
||||
def test_dependency_check(self):
|
||||
"""Test dependency checking logic."""
|
||||
# This would test _check_dependencies_installed and _install_plugin_dependencies
|
||||
# which requires mocking subprocess calls and file operations
|
||||
# Covered by test_plugin_loader.py's install_dependencies tests,
|
||||
# which exercise requirements_has_real_deps/requirements_are_satisfied
|
||||
# and the pip subprocess fallback.
|
||||
|
||||
|
||||
class TestPluginExecutor:
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tests for src/plugin_system/resource_monitor.py
|
||||
|
||||
Focus areas:
|
||||
- Execution-time metrics are captured regardless of psutil availability.
|
||||
- CPU sampling is non-blocking (regression guard for the previous
|
||||
``cpu_percent(interval=0.1)`` call that blocked 100 ms per monitored call).
|
||||
- Resource limits are enforced.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.plugin_system.resource_monitor import (
|
||||
PluginResourceMonitor,
|
||||
ResourceLimits,
|
||||
ResourceLimitExceeded,
|
||||
PSUTIL_AVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
def _cache():
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
return cache
|
||||
|
||||
|
||||
class TestExecutionTimeMetrics:
|
||||
def test_monitor_call_returns_value_and_records_call(self):
|
||||
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||
result = mon.monitor_call("p", lambda: 42)
|
||||
assert result == 42
|
||||
metrics = mon.get_metrics("p")
|
||||
assert metrics.call_count == 1
|
||||
assert metrics.total_execution_time >= 0.0
|
||||
|
||||
def test_avg_and_max_execution_time(self):
|
||||
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||
mon.monitor_call("p", lambda: time.sleep(0.01))
|
||||
mon.monitor_call("p", lambda: None)
|
||||
summary = mon.get_metrics_summary("p")
|
||||
assert summary["call_count"] == 2
|
||||
assert summary["max_execution_time"] >= summary["avg_execution_time"] >= 0.0
|
||||
|
||||
def test_exception_propagates_but_is_still_timed(self):
|
||||
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||
|
||||
def boom():
|
||||
raise ValueError("nope")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
mon.monitor_call("p", boom)
|
||||
# Execution time is still recorded even when the call raised.
|
||||
assert mon.get_metrics("p").execution_time >= 0.0
|
||||
|
||||
|
||||
class TestNonBlockingCpu:
|
||||
def test_cpu_sampling_is_fast_when_disabled(self):
|
||||
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||
start = time.time()
|
||||
for _ in range(50):
|
||||
mon._get_process_cpu_percent()
|
||||
# The old implementation blocked ~0.1s/call (~5s for 50). Non-blocking
|
||||
# must complete near-instantly.
|
||||
assert time.time() - start < 0.5
|
||||
assert mon._get_process_cpu_percent() == 0.0
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not installed")
|
||||
def test_cpu_sampling_is_fast_with_psutil(self):
|
||||
mon = PluginResourceMonitor(_cache(), enable_monitoring=True)
|
||||
assert mon._process is not None
|
||||
start = time.time()
|
||||
for _ in range(30):
|
||||
mon._get_process_cpu_percent()
|
||||
# 30 blocking 0.1s samples would be ~3s; non-blocking must be well under.
|
||||
assert time.time() - start < 0.5
|
||||
|
||||
def test_monitor_call_does_not_block_on_cpu_sampling(self):
|
||||
mon = PluginResourceMonitor(_cache()) # enable depends on psutil
|
||||
start = time.time()
|
||||
for _ in range(25):
|
||||
mon.monitor_call("p", lambda: None)
|
||||
# 25 * 0.1s = 2.5s under the old blocking bug; must be far faster now.
|
||||
assert time.time() - start < 1.0
|
||||
|
||||
|
||||
class TestResourceLimits:
|
||||
def test_execution_time_limit_raises(self):
|
||||
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||
mon.set_limits("p", ResourceLimits(max_execution_time=0.001))
|
||||
with pytest.raises(ResourceLimitExceeded):
|
||||
mon.monitor_call("p", lambda: time.sleep(0.02))
|
||||
|
||||
def test_reset_metrics_clears_counts(self):
|
||||
cache = _cache()
|
||||
mon = PluginResourceMonitor(cache, enable_monitoring=False)
|
||||
mon.monitor_call("p", lambda: None)
|
||||
assert mon.get_metrics("p").call_count == 1
|
||||
mon.reset_metrics("p")
|
||||
assert mon.get_metrics("p").call_count == 0
|
||||
|
||||
|
||||
class TestForceReload:
|
||||
def test_force_reload_refreshes_stale_snapshot(self):
|
||||
"""A read-only consumer must see the writer process's latest persisted
|
||||
metrics rather than a pinned first snapshot."""
|
||||
cache = MagicMock()
|
||||
persisted = {"value": None} # only the metrics key returns data
|
||||
|
||||
def cache_get(key, max_age=None, memory_ttl=None):
|
||||
return persisted["value"] if key.startswith("plugin_metrics:") else None
|
||||
|
||||
cache.get.side_effect = cache_get
|
||||
mon = PluginResourceMonitor(cache, enable_monitoring=False)
|
||||
|
||||
# First read snapshots empty metrics.
|
||||
assert mon.get_metrics_summary("p")["call_count"] == 0
|
||||
|
||||
# The display service later persists real metrics.
|
||||
persisted["value"] = {"call_count": 7, "total_execution_time": 1.4}
|
||||
|
||||
# Plain read stays stale...
|
||||
assert mon.get_metrics_summary("p")["call_count"] == 0
|
||||
# ...force_reload picks up the persisted values and bypasses memory.
|
||||
fresh = mon.get_metrics_summary("p", force_reload=True)
|
||||
assert fresh["call_count"] == 7
|
||||
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
|
||||
@@ -759,3 +759,54 @@ class TestDottedKeyNormalization:
|
||||
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
|
||||
assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
|
||||
assert teams == [], f"Expected empty default list, got: {teams}"
|
||||
|
||||
|
||||
class TestPluginHealthRoutes:
|
||||
"""Phase 1: /plugins/health and /plugins/metrics build per-installed-id so
|
||||
they surface cross-process data persisted by the display service."""
|
||||
|
||||
def test_health_route_builds_per_installed_id(self, client, mock_plugin_manager):
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
from src.plugin_system.plugin_health import PluginHealthTracker
|
||||
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
api_v3.plugin_manager = mock_plugin_manager
|
||||
mock_plugin_manager.plugin_manifests = {'p1': {}, 'p2': {}}
|
||||
mock_plugin_manager.health_tracker = PluginHealthTracker(cache)
|
||||
|
||||
resp = client.get('/api/v3/plugins/health')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()['data']
|
||||
assert set(data.keys()) == {'p1', 'p2'}
|
||||
assert data['p1']['is_healthy'] is True
|
||||
assert data['p1']['degraded'] is False
|
||||
|
||||
def test_health_route_reports_not_available_without_tracker(self, client, mock_plugin_manager):
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
api_v3.plugin_manager = mock_plugin_manager
|
||||
mock_plugin_manager.health_tracker = None
|
||||
|
||||
resp = client.get('/api/v3/plugins/health')
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body['data'] == {}
|
||||
assert 'not available' in body['message'].lower()
|
||||
|
||||
def test_metrics_route_builds_per_installed_id(self, client, mock_plugin_manager):
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
api_v3.plugin_manager = mock_plugin_manager
|
||||
mock_plugin_manager.plugin_manifests = {'p1': {}}
|
||||
mock_plugin_manager.resource_monitor = PluginResourceMonitor(
|
||||
cache, enable_monitoring=False
|
||||
)
|
||||
|
||||
resp = client.get('/api/v3/plugins/metrics')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()['data']
|
||||
assert 'p1' in data
|
||||
assert data['p1']['call_count'] == 0
|
||||
|
||||
@@ -160,6 +160,22 @@ api_v3.health_monitor = health_monitor
|
||||
from src.cache_manager import CacheManager
|
||||
api_v3.cache_manager = CacheManager()
|
||||
|
||||
# Wire plugin health/metrics for the web process. The display service records
|
||||
# health and execution-time metrics to the shared on-disk cache; giving the web
|
||||
# process its own tracker/monitor backed by that same cache lets the health API
|
||||
# routes (/api/v3/plugins/health, /plugins/metrics) read that persisted data.
|
||||
# Guarded so any init failure degrades to "not available" rather than breaking
|
||||
# the web server.
|
||||
try:
|
||||
from src.plugin_system.plugin_health import PluginHealthTracker
|
||||
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||
plugin_manager.health_tracker = PluginHealthTracker(api_v3.cache_manager)
|
||||
plugin_manager.resource_monitor = PluginResourceMonitor(api_v3.cache_manager)
|
||||
except Exception as _hm_err: # pragma: no cover - defensive startup guard
|
||||
logging.getLogger(__name__).warning(
|
||||
"Could not enable plugin health/metrics for web UI: %s", _hm_err
|
||||
)
|
||||
|
||||
app.register_blueprint(pages_v3, url_prefix='/v3')
|
||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||
|
||||
|
||||
@@ -329,6 +329,7 @@ def save_schedule_config():
|
||||
}
|
||||
|
||||
mode = data.get('mode', 'global')
|
||||
schedule_config['mode'] = mode
|
||||
|
||||
if mode == 'global':
|
||||
# Simple global schedule
|
||||
@@ -2073,6 +2074,18 @@ def get_installed_plugins():
|
||||
return None
|
||||
|
||||
def _build_plugin_entry_inner(plugin_info, plugin_id):
|
||||
# Capture runtime state (state machine + error context) before the
|
||||
# manifest merge below can shadow the 'state' key. get_all_plugin_info
|
||||
# attaches this via PluginStateManager.get_state_info(); surfacing it
|
||||
# lets the UI show *why* a plugin isn't running instead of just
|
||||
# 'loaded: false'.
|
||||
state_info = plugin_info.get('state')
|
||||
plugin_state = None
|
||||
plugin_error_info = None
|
||||
if isinstance(state_info, dict):
|
||||
plugin_state = state_info.get('state')
|
||||
plugin_error_info = state_info.get('error_info')
|
||||
|
||||
# Re-read manifest from disk to ensure we have the latest metadata
|
||||
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
@@ -2154,6 +2167,8 @@ def get_installed_plugins():
|
||||
'enabled': enabled,
|
||||
'verified': verified,
|
||||
'loaded': plugin_info.get('loaded', False),
|
||||
'state': plugin_state,
|
||||
'error_info': plugin_error_info,
|
||||
'last_updated': last_updated,
|
||||
'last_commit': last_commit,
|
||||
'last_commit_message': last_commit_message,
|
||||
@@ -2173,6 +2188,31 @@ def get_installed_plugins():
|
||||
logger.error('Error in get_installed_plugins', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
|
||||
def _installed_plugin_ids():
|
||||
"""Best-effort list of installed plugin IDs for the web process.
|
||||
|
||||
Health/metrics state is written by the separate display service to the
|
||||
shared on-disk cache, so the tracker's in-memory set is empty here. We
|
||||
enumerate the installed plugins and read each one's persisted summary by ID
|
||||
instead of relying on the tracker's in-memory `get_all_*` view.
|
||||
"""
|
||||
pm = api_v3.plugin_manager
|
||||
manifests = getattr(pm, 'plugin_manifests', None)
|
||||
if not manifests:
|
||||
# Only pay for a discovery scan when we haven't discovered anything yet;
|
||||
# subsequent polls reuse the already-populated manifest map.
|
||||
try:
|
||||
pm.discover_plugins()
|
||||
except Exception:
|
||||
logger.debug('discover_plugins failed while listing plugin ids', exc_info=True)
|
||||
manifests = getattr(pm, 'plugin_manifests', None)
|
||||
try:
|
||||
return list(manifests.keys()) if manifests else []
|
||||
except Exception:
|
||||
logger.debug('listing plugin_manifests failed while building plugin ids', exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@api_v3.route('/plugins/health', methods=['GET'])
|
||||
def get_plugin_health():
|
||||
"""Get health metrics for all plugins"""
|
||||
@@ -2188,8 +2228,23 @@ def get_plugin_health():
|
||||
'message': 'Health tracking not available'
|
||||
})
|
||||
|
||||
# Get health summaries for all plugins
|
||||
health_summaries = api_v3.plugin_manager.health_tracker.get_all_health_summaries()
|
||||
tracker = api_v3.plugin_manager.health_tracker
|
||||
# Build per-plugin summaries by ID so persisted (cross-process) health
|
||||
# is included, then fold in any in-memory-only entries.
|
||||
health_summaries = {}
|
||||
for pid in _installed_plugin_ids():
|
||||
try:
|
||||
# force_reload: this process only reads; bypass the in-memory
|
||||
# snapshot so each poll reflects the display service's latest
|
||||
# persisted state.
|
||||
health_summaries[pid] = tracker.get_health_summary(pid, force_reload=True)
|
||||
except Exception:
|
||||
logger.debug('Could not read health summary for %s', pid, exc_info=True)
|
||||
try:
|
||||
for pid, summary in tracker.get_all_health_summaries().items():
|
||||
health_summaries.setdefault(pid, summary)
|
||||
except Exception:
|
||||
logger.debug('get_all_health_summaries failed', exc_info=True)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
@@ -2264,8 +2319,22 @@ def get_plugin_metrics():
|
||||
'message': 'Resource monitoring not available'
|
||||
})
|
||||
|
||||
# Get metrics summaries for all plugins
|
||||
metrics_summaries = api_v3.plugin_manager.resource_monitor.get_all_metrics_summaries()
|
||||
monitor = api_v3.plugin_manager.resource_monitor
|
||||
# Build per-plugin summaries by ID so persisted (cross-process) metrics
|
||||
# are included, then fold in any in-memory-only entries.
|
||||
metrics_summaries = {}
|
||||
for pid in _installed_plugin_ids():
|
||||
try:
|
||||
# force_reload: read-only path — bypass the in-memory snapshot so
|
||||
# each poll reflects the display service's latest persisted metrics.
|
||||
metrics_summaries[pid] = monitor.get_metrics_summary(pid, force_reload=True)
|
||||
except Exception:
|
||||
logger.debug('Could not read metrics summary for %s', pid, exc_info=True)
|
||||
try:
|
||||
for pid, summary in monitor.get_all_metrics_summaries().items():
|
||||
metrics_summaries.setdefault(pid, summary)
|
||||
except Exception:
|
||||
logger.debug('get_all_metrics_summaries failed', exc_info=True)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
@@ -7184,6 +7253,74 @@ def set_auto_enable_ap_mode():
|
||||
'message': 'An error occurred; see logs for details'
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/radio', methods=['GET'])
|
||||
def get_wifi_radio():
|
||||
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
state = wifi_manager.get_wifi_radio_state()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': state
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting WiFi radio state", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/radio', methods=['POST'])
|
||||
def set_wifi_radio():
|
||||
"""Turn the WiFi radio on or off.
|
||||
|
||||
Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused
|
||||
unless Ethernet is connected or force=True, to avoid locking the user out
|
||||
of this web interface.
|
||||
"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if 'enabled' not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'enabled is required'
|
||||
}), 400
|
||||
|
||||
# Parse defensively: bool("false") is True, so mirror the string-aware
|
||||
# coercion used for `force` — the endpoint is a public contract, not just
|
||||
# the shipped UI (which always sends real JSON booleans).
|
||||
_enabled_raw = data['enabled']
|
||||
enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes'))
|
||||
_force_raw = data.get('force', False)
|
||||
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes'))
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': message,
|
||||
'data': wifi_manager.get_wifi_radio_state()
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message,
|
||||
'reason': reason
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("Error setting WiFi radio state", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/cache/list', methods=['GET'])
|
||||
def list_cache_files():
|
||||
"""List all cache files with metadata"""
|
||||
|
||||
@@ -340,11 +340,25 @@ const PluginAPI = {
|
||||
* @returns {Promise<Object>} Health data
|
||||
*/
|
||||
async getPluginHealth(pluginId = null) {
|
||||
const endpoint = pluginId
|
||||
const endpoint = pluginId
|
||||
? `/plugins/health/${pluginId}`
|
||||
: '/plugins/health';
|
||||
const response = await this.request(endpoint);
|
||||
return response.data || {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get plugin resource metrics (execution time, memory, cpu).
|
||||
*
|
||||
* @param {string} pluginId - Optional plugin identifier (null for all)
|
||||
* @returns {Promise<Object>} Metrics data keyed by plugin id
|
||||
*/
|
||||
async getPluginMetrics(pluginId = null) {
|
||||
const endpoint = pluginId
|
||||
? `/plugins/metrics/${pluginId}`
|
||||
: '/plugins/metrics';
|
||||
const response = await this.request(endpoint);
|
||||
return response.data || {};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -134,10 +134,15 @@
|
||||
|
||||
function openResults() {
|
||||
resultsBox.classList.remove('hidden');
|
||||
// .hidden has no effect without a matching CSS rule (this app's stylesheet
|
||||
// is a hand-picked utility subset, not full Tailwind) - force it directly,
|
||||
// same as the revealNode/collapseNode fallback below.
|
||||
resultsBox.style.display = '';
|
||||
if (input) input.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
function closeResults() {
|
||||
resultsBox.classList.add('hidden');
|
||||
resultsBox.style.display = 'none';
|
||||
activeIndex = -1;
|
||||
if (input) {
|
||||
input.setAttribute('aria-expanded', 'false');
|
||||
@@ -295,7 +300,12 @@
|
||||
input.addEventListener('input', debounce(function () {
|
||||
var q = input.value;
|
||||
if (!q.trim()) { closeResults(); return; }
|
||||
buildIndex().then(function () { renderResults(search(q)); });
|
||||
// Focus may have left during the debounce (typed then clicked away);
|
||||
// don't re-open a dropdown the user has already dismissed.
|
||||
if (document.activeElement !== input) return;
|
||||
buildIndex().then(function () {
|
||||
if (document.activeElement === input) renderResults(search(q));
|
||||
});
|
||||
}, 200));
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
@@ -329,11 +339,18 @@
|
||||
if (chosen) navigateToSetting(chosen);
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!input) return;
|
||||
if (e.target === input || resultsBox.contains(e.target)) return;
|
||||
closeResults();
|
||||
});
|
||||
// Close when a click/tap lands outside the search widget. Capture phase
|
||||
// (the `true`) runs on the way DOWN, before any bubbling stopPropagation
|
||||
// from Alpine/HTMX/widget handlers can swallow the event — a plain
|
||||
// bubble-phase document listener was being eaten and never closing us.
|
||||
// pointerdown also covers touch (Raspberry Pi screen).
|
||||
document.addEventListener('pointerdown', function (e) {
|
||||
if (!input || resultsBox.classList.contains('hidden')) return;
|
||||
var wrap = document.getElementById('settings-search-wrap');
|
||||
var inside = wrap ? wrap.contains(e.target)
|
||||
: (e.target === input || resultsBox.contains(e.target));
|
||||
if (!inside) closeResults();
|
||||
}, true);
|
||||
|
||||
// Reliable dismiss: close shortly after focus leaves the box. Result
|
||||
// selection uses mousedown + preventDefault (focus stays on the input),
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
<div class="space-y-6" id="tools-root">
|
||||
|
||||
<!-- System Diagnostics -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">System Diagnostics</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Live CPU, memory, temperature, disk, and uptime for this Raspberry Pi.</p>
|
||||
</div>
|
||||
<button id="btn-diag-refresh" onclick="loadSystemDiagnostics()"
|
||||
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>Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="diag-panel" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="animate-pulse text-gray-400 col-span-full">Loading diagnostics…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Git & Updates -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
@@ -123,6 +141,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Radio -->
|
||||
<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">Network Radio</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Turn the WiFi radio on or off. Full WiFi setup (scan, connect, hotspot) lives on the <span class="font-medium">WiFi</span> tab.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">WiFi radio</p>
|
||||
<p id="wifi-radio-note" class="text-xs text-gray-500 mt-0.5">Checking current state…</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex flex-col items-end gap-2">
|
||||
<!-- Toggle switch -->
|
||||
<button id="wifi-radio-toggle" type="button" role="switch" aria-checked="false"
|
||||
onclick="onWifiRadioToggleClick()" disabled
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 opacity-60">
|
||||
<span id="wifi-radio-knob" aria-hidden="true"
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out translate-x-0"></span>
|
||||
</button>
|
||||
|
||||
<!-- Force-off confirm row (shown only when disabling without Ethernet is refused) -->
|
||||
<div id="wifi-radio-force-row" class="hidden flex-col items-end gap-2">
|
||||
<span class="text-xs text-red-700 font-medium text-right max-w-xs">No wired connection detected — turning WiFi off will disconnect you from this page. Continue anyway?</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="forceDisableWifiRadio()"
|
||||
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">
|
||||
Turn WiFi off anyway
|
||||
</button>
|
||||
<button onclick="hideWifiForceRow()"
|
||||
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>
|
||||
<div id="result-wifi-radio" class="hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
@@ -157,6 +215,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Power -->
|
||||
<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">System Power</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Reboot or shut down the Raspberry Pi. The web interface will go offline.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Reboot -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Reboot</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo reboot</code>. The Pi will restart and come back online in a minute or two.</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex flex-col items-end gap-2">
|
||||
<button id="btn-reboot" onclick="showPowerConfirm('reboot')"
|
||||
class="inline-flex items-center px-3 py-2 border border-amber-300 text-sm font-medium rounded-md text-amber-700 bg-white hover:bg-amber-50">
|
||||
<i class="fas fa-power-off mr-2"></i>Reboot…
|
||||
</button>
|
||||
<div id="reboot-confirm-row" class="hidden flex items-center gap-2">
|
||||
<span class="text-xs text-amber-700 font-medium">Reboot now?</span>
|
||||
<button onclick="powerAction('reboot_system', 'btn-reboot', 'result-reboot', 'Reboot command sent — the Pi is restarting. This page will go offline and should return in a minute or two.'); hidePowerConfirm('reboot')"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700">
|
||||
Yes, reboot
|
||||
</button>
|
||||
<button onclick="hidePowerConfirm('reboot')"
|
||||
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-reboot" class="hidden"></div>
|
||||
|
||||
<!-- Shutdown -->
|
||||
<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">Shut down</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo poweroff</code>. The Pi will power off and must be unplugged/replugged (or power-cycled) to turn back on.</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex flex-col items-end gap-2">
|
||||
<button id="btn-shutdown" onclick="showPowerConfirm('shutdown')"
|
||||
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-plug mr-2"></i>Shut Down…
|
||||
</button>
|
||||
<div id="shutdown-confirm-row" class="hidden flex items-center gap-2">
|
||||
<span class="text-xs text-red-700 font-medium">Power off now?</span>
|
||||
<button onclick="powerAction('shutdown_system', 'btn-shutdown', 'result-shutdown', 'Shutdown command sent — the Pi is powering off. You will need to power-cycle it to turn it back on.'); hidePowerConfirm('shutdown')"
|
||||
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, shut down
|
||||
</button>
|
||||
<button onclick="hidePowerConfirm('shutdown')"
|
||||
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-shutdown" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Health -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Plugin Health</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Circuit-breaker status and per-plugin update timings recorded by the display service. A plugin whose <code class="bg-gray-100 px-1 rounded">update()</code> keeps failing is paused ("Circuit open") and retried automatically after a cooldown.</p>
|
||||
</div>
|
||||
<button id="btn-plugin-health-refresh" onclick="refreshPluginHealth(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-sync-alt mr-2"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="plugin-health-message" class="hidden mb-4 text-sm text-gray-500"></div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plugin</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg update</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Max update</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Updates</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plugin-health-tbody" class="bg-white divide-y divide-gray-200">
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -397,7 +549,290 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── system diagnostics panel ──────────────────────────────────────────────
|
||||
// Reads the existing /api/v3/system/status JSON endpoint (10s cached) for
|
||||
// richer metrics (disk, uptime, memory MB) than the SSE stats stream carries.
|
||||
|
||||
function diagTile(icon, iconColor, label, value, sub) {
|
||||
return `
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0"><i class="fas ${icon} ${iconColor} text-xl"></i></div>
|
||||
<div class="ml-3 w-0 flex-1">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">${escHtml(label)}</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">${escHtml(value)}</dd>
|
||||
${sub ? `<dd class="text-xs text-gray-400 mt-0.5">${escHtml(sub)}</dd>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window.loadSystemDiagnostics = function() {
|
||||
const panel = document.getElementById('diag-panel');
|
||||
if (!panel) return;
|
||||
|
||||
fetch('/api/v3/system/status')
|
||||
.then(r => {
|
||||
if (!r.ok) return r.json()
|
||||
.then(d => Promise.reject(d.message || `HTTP ${r.status}`))
|
||||
.catch(() => Promise.reject(`HTTP ${r.status}`));
|
||||
return r.json();
|
||||
})
|
||||
.then(res => {
|
||||
const d = (res && res.data) || {};
|
||||
const mUsedGb = d.memory_used_mb != null ? (d.memory_used_mb / 1024).toFixed(1) : null;
|
||||
const mTotGb = d.memory_total_mb != null ? (d.memory_total_mb / 1024).toFixed(1) : null;
|
||||
const temp = d.cpu_temp != null ? d.cpu_temp + '°C' : 'N/A';
|
||||
panel.innerHTML =
|
||||
diagTile('fa-microchip', 'text-blue-600', 'CPU Usage',
|
||||
(d.cpu_percent != null ? d.cpu_percent : '--') + '%', null) +
|
||||
diagTile('fa-memory', 'text-green-600', 'Memory',
|
||||
(d.memory_used_percent != null ? d.memory_used_percent : '--') + '%',
|
||||
(mUsedGb && mTotGb) ? `${mUsedGb} / ${mTotGb} GB` : null) +
|
||||
diagTile('fa-thermometer-half', 'text-red-600', 'CPU Temp', temp, null) +
|
||||
diagTile('fa-hdd', 'text-indigo-600', 'Disk',
|
||||
(d.disk_used_percent != null ? d.disk_used_percent : '--') + '%',
|
||||
(d.disk_used_gb != null && d.disk_total_gb != null) ? `${d.disk_used_gb} / ${d.disk_total_gb} GB` : null) +
|
||||
diagTile('fa-clock', 'text-purple-600', 'Uptime', d.uptime || '--', null) +
|
||||
diagTile('fa-desktop', d.service_active ? 'text-green-600' : 'text-gray-400',
|
||||
'Display Service', d.service_active ? 'Active' : 'Inactive', null);
|
||||
})
|
||||
.catch(err => {
|
||||
panel.innerHTML = `<div class="col-span-full text-sm text-red-600">Diagnostics unavailable: ${escHtml(String(err))}</div>`;
|
||||
});
|
||||
};
|
||||
|
||||
// ── system power (reboot / shutdown) ──────────────────────────────────────
|
||||
// Like toolsAction, but a dropped connection is the expected, successful
|
||||
// outcome (the Pi is going down), so it is reported as info, not an error.
|
||||
|
||||
window.powerAction = function(action, btnId, resultId, offlineMsg) {
|
||||
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().catch(() => ({status: 'success'})))
|
||||
.then(data => {
|
||||
if (data.status === 'error') {
|
||||
showResult(resultId, false, data.message || 'Command failed');
|
||||
} else {
|
||||
showResult(resultId, true, offlineMsg);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Connection dropped mid-request — expected when the Pi reboots/powers off.
|
||||
showResult(resultId, true, offlineMsg);
|
||||
})
|
||||
.finally(() => setBusy(btnId, false));
|
||||
};
|
||||
|
||||
window.showPowerConfirm = function(kind) {
|
||||
document.getElementById(kind + '-confirm-row').classList.remove('hidden');
|
||||
document.getElementById('btn-' + kind).classList.add('hidden');
|
||||
};
|
||||
window.hidePowerConfirm = function(kind) {
|
||||
document.getElementById(kind + '-confirm-row').classList.add('hidden');
|
||||
document.getElementById('btn-' + kind).classList.remove('hidden');
|
||||
};
|
||||
|
||||
// ── WiFi radio toggle ─────────────────────────────────────────────────────
|
||||
|
||||
function renderWifiRadio(state) {
|
||||
const toggle = document.getElementById('wifi-radio-toggle');
|
||||
const knob = document.getElementById('wifi-radio-knob');
|
||||
const note = document.getElementById('wifi-radio-note');
|
||||
if (!toggle || !knob || !note) return;
|
||||
|
||||
const setKnob = (on) => {
|
||||
if (on) {
|
||||
knob.classList.remove('translate-x-0'); knob.classList.add('translate-x-5');
|
||||
toggle.classList.remove('bg-gray-200'); toggle.classList.add('bg-blue-600');
|
||||
} else {
|
||||
knob.classList.remove('translate-x-5'); knob.classList.add('translate-x-0');
|
||||
toggle.classList.remove('bg-blue-600'); toggle.classList.add('bg-gray-200');
|
||||
}
|
||||
};
|
||||
|
||||
if (!state || state.available === false) {
|
||||
toggle.disabled = true;
|
||||
toggle.classList.add('opacity-60');
|
||||
toggle.setAttribute('aria-checked', 'false');
|
||||
setKnob(false);
|
||||
note.textContent = 'WiFi radio control is not available on this system (nmcli not found).';
|
||||
note.className = 'text-xs text-gray-500 mt-0.5';
|
||||
return;
|
||||
}
|
||||
|
||||
const on = state.enabled === true;
|
||||
toggle.disabled = false;
|
||||
toggle.classList.remove('opacity-60');
|
||||
toggle.setAttribute('aria-checked', on ? 'true' : 'false');
|
||||
setKnob(on);
|
||||
const eth = state.ethernet_connected
|
||||
? 'Wired connection detected — safe to turn WiFi off.'
|
||||
: 'No wired connection — turning WiFi off will disconnect this page.';
|
||||
note.textContent = `Radio is ${on ? 'on' : 'off'}. ${eth}`;
|
||||
note.className = 'text-xs mt-0.5 ' + (state.ethernet_connected ? 'text-gray-500' : 'text-amber-600');
|
||||
}
|
||||
|
||||
window.loadWifiRadio = function() {
|
||||
fetch('/api/v3/wifi/radio')
|
||||
.then(r => r.json())
|
||||
.then(res => renderWifiRadio(res && res.data))
|
||||
.catch(() => renderWifiRadio(null));
|
||||
};
|
||||
|
||||
window.onWifiRadioToggleClick = function() {
|
||||
const toggle = document.getElementById('wifi-radio-toggle');
|
||||
if (!toggle || toggle.disabled) return;
|
||||
const currentlyOn = toggle.getAttribute('aria-checked') === 'true';
|
||||
setWifiRadio(!currentlyOn, false);
|
||||
};
|
||||
|
||||
function setWifiRadio(enabled, force) {
|
||||
const toggle = document.getElementById('wifi-radio-toggle');
|
||||
const resultEl = document.getElementById('result-wifi-radio');
|
||||
if (toggle) toggle.disabled = true;
|
||||
if (resultEl) resultEl.classList.add('hidden');
|
||||
hideWifiForceRow();
|
||||
|
||||
fetch('/api/v3/wifi/radio', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({enabled, force})
|
||||
})
|
||||
.then(r => r.json().then(d => ({ok: r.ok, d})))
|
||||
.then(({ok, d}) => {
|
||||
if (ok && d.status === 'success') {
|
||||
if (d.data) renderWifiRadio(d.data); else window.loadWifiRadio();
|
||||
showResult('result-wifi-radio', true, d.message || 'Done');
|
||||
} else if (!enabled && !force && d.reason === 'no_ethernet') {
|
||||
// Disable refused for safety (no wired fallback) — offer the force path.
|
||||
showWifiForceRow();
|
||||
window.loadWifiRadio();
|
||||
} else {
|
||||
showResult('result-wifi-radio', false, d.message || 'Failed to change WiFi radio.');
|
||||
window.loadWifiRadio();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showResult('result-wifi-radio', false, 'Request failed: ' + err.message);
|
||||
window.loadWifiRadio();
|
||||
})
|
||||
.finally(() => { if (toggle) toggle.disabled = false; });
|
||||
}
|
||||
|
||||
window.forceDisableWifiRadio = function() {
|
||||
hideWifiForceRow();
|
||||
setWifiRadio(false, true);
|
||||
};
|
||||
|
||||
function showWifiForceRow() {
|
||||
const row = document.getElementById('wifi-radio-force-row');
|
||||
if (row) { row.classList.remove('hidden'); row.classList.add('flex'); }
|
||||
}
|
||||
window.hideWifiForceRow = function() {
|
||||
const row = document.getElementById('wifi-radio-force-row');
|
||||
if (row) { row.classList.add('hidden'); row.classList.remove('flex'); }
|
||||
};
|
||||
|
||||
// ── plugin health panel ──────────────────────────────────────────────────
|
||||
function phEscape(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function phFmtSecs(v) {
|
||||
if (typeof v !== 'number' || !isFinite(v)) return '—';
|
||||
return v.toFixed(3) + 's';
|
||||
}
|
||||
function phStatus(h) {
|
||||
if (!h) return { label: 'Unknown', cls: 'warning' };
|
||||
if (h.circuit_state === 'open') return { label: 'Circuit open', cls: 'error' };
|
||||
if (h.circuit_state === 'half_open') return { label: 'Recovering', cls: 'warning' };
|
||||
if (h.degraded) return { label: 'Degraded', cls: 'warning' };
|
||||
if (h.is_healthy) return { label: 'Healthy', cls: 'success' };
|
||||
return { label: 'Unknown', cls: 'warning' };
|
||||
}
|
||||
async function refreshPluginHealth(force) {
|
||||
const tbody = document.getElementById('plugin-health-tbody');
|
||||
const msg = document.getElementById('plugin-health-message');
|
||||
if (!tbody || !window.PluginAPI) return;
|
||||
try {
|
||||
if (force && PluginAPI.clearCache) PluginAPI.clearCache();
|
||||
const results = await Promise.all([
|
||||
PluginAPI.getPluginHealth(),
|
||||
PluginAPI.getPluginMetrics()
|
||||
]);
|
||||
const health = results[0] || {};
|
||||
const metrics = results[1] || {};
|
||||
const ids = Array.from(new Set(Object.keys(health).concat(Object.keys(metrics)))).sort();
|
||||
if (!ids.length) {
|
||||
if (msg) {
|
||||
msg.textContent = 'No plugin health data yet — it appears once the display service has run plugins.';
|
||||
msg.classList.remove('hidden');
|
||||
}
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">No data</td></tr>';
|
||||
return;
|
||||
}
|
||||
if (msg) msg.classList.add('hidden');
|
||||
let rows = '';
|
||||
ids.forEach(function (id) {
|
||||
const h = health[id] || {};
|
||||
const m = metrics[id] || {};
|
||||
const st = phStatus(h);
|
||||
const lastErr = h.degraded_reason || h.last_error || '';
|
||||
const calls = (typeof m.call_count === 'number') ? m.call_count : '—';
|
||||
const errCell = lastErr
|
||||
? '<span title="' + phEscape(lastErr) + '">' + phEscape(lastErr) + '</span>'
|
||||
: '<span class="text-gray-400">—</span>';
|
||||
rows += '<tr>' +
|
||||
'<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">' + phEscape(id) + '</td>' +
|
||||
'<td class="px-4 py-3 whitespace-nowrap"><span class="status-indicator ' + st.cls + '">' + st.label + '</span></td>' +
|
||||
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + phFmtSecs(m.avg_execution_time) + '</td>' +
|
||||
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + phFmtSecs(m.max_execution_time) + '</td>' +
|
||||
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + calls + '</td>' +
|
||||
'<td class="px-4 py-3 text-sm text-red-600 max-w-xs truncate">' + errCell + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
tbody.innerHTML = rows;
|
||||
} catch (e) {
|
||||
const emsg = (e && e.message) ? e.message : String(e);
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="px-4 py-6 text-center text-red-500">Failed to load plugin health: ' + phEscape(emsg) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
window.refreshPluginHealth = refreshPluginHealth;
|
||||
|
||||
// Load on first render; HTMX will have already swapped us in by this point.
|
||||
loadGitInfo();
|
||||
|
||||
// Plugin health: initial load + periodic refresh. Guard against duplicate
|
||||
// timers if this partial is re-swapped in by HTMX; the handler re-resolves
|
||||
// DOM nodes by id each tick.
|
||||
refreshPluginHealth(false);
|
||||
if (!window._pluginHealthTimer) {
|
||||
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
|
||||
}
|
||||
|
||||
// System diagnostics: load now, then refresh every 10s. Clear any prior
|
||||
// interval so re-swapping the partial doesn't stack. The recurring poll is
|
||||
// gated on visibility — the partial stays in the DOM (hidden via x-show)
|
||||
// when another tab is active, so without this it would keep hitting
|
||||
// /api/v3/system/status every 10s and churn the Pi while off-screen.
|
||||
if (window._diagPollInterval) clearInterval(window._diagPollInterval);
|
||||
window.loadSystemDiagnostics();
|
||||
window._diagPollInterval = setInterval(function () {
|
||||
const panel = document.getElementById('diag-panel');
|
||||
if (!panel || document.hidden || panel.offsetParent === null) return;
|
||||
window.loadSystemDiagnostics();
|
||||
}, 10000);
|
||||
|
||||
// WiFi radio current state.
|
||||
window.loadWifiRadio();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user