Compare commits

..
Author SHA1 Message Date
Claude e79a07f259 Fix stuck search dropdown; add plugin-tab nested-settings filter
Global search:
- Close the results dropdown on input blur (guarded, with a short delay)
  so it reliably dismisses when focus leaves — previously it could linger
  because the only outside-close was a document click that Alpine/HTMX
  handlers can swallow.
- Clear the query text after navigating to a result so refocusing the box
  doesn't re-open stale results.
- Also dismiss on htmx:afterSwap (tab changes / navigation).

Per-tab filter (now on plugin tabs too):
- Render the shared settings_filter box in the plugin Configuration panel.
  It auto-wires: the delegated input handler and filterScope already target
  .plugin-config-tab.
- Teach applyTabFilter to reveal matches inside collapsed nested sections
  (render_nested_section defaults them shut), hide nested-section wrappers
  with no matches, and restore the original collapsed layout when cleared
  (only re-collapsing sections the filter itself opened).
- Count a visible nested-section as content for its parent heading so the
  heading isn't hidden while a subsection below still has matches.

Adds a plugin-config render test (filter box + nested anchors + tooltips).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz
2026-07-08 20:26:04 +00:00
Claude 497a103d8c Address CodeRabbit review on settings search/tooltips
- pages_v3: include Durations tab in the search index so
  setting-durations-* fields are actually indexed
- test_web_settings_ui: assert setting-durations-clock is present in
  the search-index endpoint response
- settings-search.js: on index fetch failure, reset buildPromise
  instead of caching an empty (truthy) index so search can retry
- settings-search.js: filterScope returns null (not document) when no
  tab container matches, and the caller guards, so the per-tab filter
  can't hide fields across unrelated tabs
- settings-search.js: refresh the stale header comment to describe the
  server-side JSON index flow
- app.css: cap #settings-search-results height with overflow-y so the
  dropdown scrolls instead of overflowing small screens

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz
2026-07-08 17:20:03 +00:00
Claude a2fb3d180f Serve the settings search index from the server as JSON
Move index building off the client so there is no client-side HTML fetching
or DOM parsing (resolves Codacy's variable-fetch and DOMParser flags on the
read-only, same-origin index build).

- Add GET /v3/settings/search-index (pages_v3.py): renders the settings
  partials server-side and extracts each field's anchor id, key, label,
  tooltip, and section with a small stdlib HTMLParser, then caches the result
  keyed on the installed-plugin set. Parsing the rendered HTML keeps anchor
  ids identical to the live DOM, so the index cannot drift.
- settings-search.js: buildIndex() now does a single fetch of the literal
  endpoint + .json(); removed the per-partial fetch loop, DOMParser, scanDoc,
  CORE_TABS, and the plugin-id allowlist. Search, keyboard nav, navigation,
  and the per-tab filter are unchanged.

Net: fewer requests and no client-side HTML parsing. Verified with a new
endpoint test in test_web_settings_ui.py (12 pass) and the headless-Chromium
test (tooltip, filter, search navigate + flash all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz
2026-07-08 16:11:11 +00:00
Claude c0d785967b Resolve remaining Codacy findings in settings search
- Validate plugin ids against a strict allowlist (mirroring the server's
  _SAFE_PLUGIN_ID_RE) before they can appear in a fetch path, so the request
  URL is never built from unvalidated input (Codacy: user-controlled URL).
- Document that the fetched HTML is parsed into an inert document (scripts
  never run, never inserted into the live DOM) purely to read field text for
  the search index.
- Declare block-scoped locals with const instead of var where they were
  nested inside conditionals (Codacy: var not at function root).

No behavior change; re-verified with ESLint and the headless-Chromium test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz
2026-07-08 15:40:22 +00:00
Claude 6e3f334e10 Reduce complexity of revealAncestors in settings search
Extract isNodeHidden() and revealNode() helpers so revealAncestors drops
below the cyclomatic-complexity threshold. No behavior change; verified with
the headless-Chromium test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz
2026-07-08 15:19:53 +00:00
Claude 22d3d8c1ff Address Codacy static-analysis findings in settings JS
Refactor the two new modules to clear the flagged patterns without any
behavior change:

- Build the search dropdown with DOM nodes + textContent instead of
  innerHTML string concatenation, removing the XSS sinks and the manual
  escapeHtml helper it needed.
- Replace numeric index access (index[i], terms[j], opts[idx],
  currentResults[i]) with array iteration methods, NodeList.item(), and
  Array.prototype.at() to clear detect-object-injection.
- Use === via a shared termsMatch() helper, optional-catch binding, and
  drop a useless initial assignment.

Verified with ESLint (eslint:recommended + eslint-plugin-security) at zero
findings and re-ran the headless-Chromium behavior test (tooltip, per-tab
filter, global search navigation) — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz
2026-07-08 15:13:26 +00:00
Claude 58a28f3a14 Add settings tooltips and search to the web UI
Help users quickly find settings and understand how each one works.

Tooltips: a new delegated controller (static/v3/js/tooltips.js) drives an
accessible (i) info tooltip that appears on hover, keyboard focus, and tap.
A shared `help_tip` Jinja macro (partials/_macros.html) emits the trigger;
the plugin config macro and the core settings partials now surface help
text through it. Per the design, the always-visible field help paragraphs
are folded into the tooltip to declutter the forms, and the hardware/display
settings carry authored detail (default, range, recommendation).

Search: a global header search box finds settings across every settings tab
— even ones not yet opened — via a lazy client-side index built by scanning
the same field markup (static/v3/js/settings-search.js). Selecting a result
switches tabs, waits for the field to load, then scrolls to and flashes it.
A per-tab filter box hides non-matching fields on the current tab.

Plugin settings get tooltips for free by reusing each field's schema
`description`; every settings field also gets a stable `setting-<tab>-<key>`
anchor id for search navigation.

Styling uses the existing --color-* theme vars so light/dark mode both work,
and honors prefers-reduced-motion. Adds Flask render smoke tests that assert
each settings partial ships tooltips, anchors, and a filter box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz
2026-07-08 15:01:19 +00:00
29 changed files with 299 additions and 2133 deletions
-3
View File
@@ -43,9 +43,6 @@ websocket-client>=1.8.0,<2.0.0
# JSON Schema validation # JSON Schema validation
jsonschema>=4.20.0,<5.0.0 jsonschema>=4.20.0,<5.0.0
# Requirement specifier parsing (plugin dependency satisfaction checks)
packaging>=23.0,<27.0
# Testing dependencies # Testing dependencies
pytest>=9.0.3,<10.0.0 pytest>=9.0.3,<10.0.0
pytest-cov>=4.1.0,<5.0.0 pytest-cov>=4.1.0,<5.0.0
+29
View File
@@ -0,0 +1,29 @@
#!/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."
+5 -12
View File
@@ -68,15 +68,14 @@ class DiskCache:
return None return None
return os.path.join(self.cache_dir, f"{key}.json") return os.path.join(self.cache_dir, f"{key}.json")
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]: def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
""" """
Get data from disk cache. Get data from disk cache.
Args: Args:
key: Cache key key: Cache key
max_age: Maximum age in seconds; None disables age-based expiry max_age: Maximum age in seconds
(the record never counts as stale). Mirrors MemoryCache.get.
Returns: Returns:
Cached data or None if not found or expired Cached data or None if not found or expired
""" """
@@ -106,13 +105,7 @@ class DiskCache:
record_ts = None record_ts = None
now = time.time() now = time.time()
# max_age=None means "never expires" (mirrors MemoryCache and the if record_ts is None or (now - record_ts) <= max_age:
# 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 return record
else: else:
# Stale on disk; keep file for potential diagnostics but treat as miss # Stale on disk; keep file for potential diagnostics but treat as miss
+3 -13
View File
@@ -574,19 +574,9 @@ class CacheManager:
} }
return self.save_cache(data_type, cache_data) return self.save_cache(data_type, cache_data)
def get(self, key: str, max_age: Optional[int] = 300, def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]: """Get data from cache if it exists and is not stale."""
"""Get data from cache if it exists and is not stale. cached_data = self.get_cached_data(key, max_age)
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: if cached_data and 'data' in cached_data:
return cached_data['data'] return cached_data['data']
return cached_data return cached_data
+8 -43
View File
@@ -8,7 +8,6 @@ files that need to be accessible by both root service and web user.
import os import os
import logging import logging
import re
import shutil as _shutil import shutil as _shutil
import subprocess import subprocess
import sys import sys
@@ -17,25 +16,6 @@ from typing import Optional
logger = logging.getLogger(__name__) 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 # System directories that should never have their permissions modified
# These directories have special system-level permissions that must be preserved # 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 PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
@@ -358,13 +338,6 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
["sudo", "-n", bash_path, str(wrapper), str(req_file)], ["sudo", "-n", bash_path, str(wrapper), str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(project_root) 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: if result.returncode == 0:
return result return result
# Distinguish "sudo rejected this exact command line" (worth # Distinguish "sudo rejected this exact command line" (worth
@@ -375,24 +348,16 @@ 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") for phrase in ("a password is required", "is not allowed to run", "no tty present")
) )
if not denied: 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( logger.warning(
"Root pip install failed (rc=%s); see the returned " "Root pip install failed (rc=%s) for %s: %s",
"CompletedProcess.stderr for details.", result.returncode, req_file, result.stderr.strip()[:500],
result.returncode,
) )
return result return result
# Same reasoning as above: no req_file / pip-output interpolation in
# this log line, only in the returned note/CompletedProcess.
logger.warning( logger.warning(
"Root pip install wrapper denied via sudo for all candidates; " "Root pip install wrapper denied via sudo for %s; falling back to "
"falling back to user-level install. See the returned " "user-level install: %s",
"CompletedProcess.stderr for details." req_file, result.stderr.strip()[:500] if result else "no bash candidates found",
) )
note = ( note = (
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); " f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
@@ -402,7 +367,8 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
) )
else: else:
logger.warning( logger.warning(
"safe_pip_install.sh not found; falling back to user-level install." "safe_pip_install.sh not found; falling back to user-level install for %s",
req_file,
) )
note = ( note = (
"[safe_pip_install.sh not found; installed for the current process's " "[safe_pip_install.sh not found; installed for the current process's "
@@ -420,7 +386,6 @@ 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)], [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) capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
) )
result.stderr = _redact_url_credentials(result.stderr) result.stdout = note + (result.stdout or "")
result.stdout = note + _redact_url_credentials(result.stdout)
return result return result
+1 -18
View File
@@ -230,24 +230,7 @@ class DisplayController:
cache_manager=self.cache_manager, cache_manager=self.cache_manager,
font_manager=self.font_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 # Validate plugins after plugin manager is created
try: try:
from src.startup_validator import StartupValidator from src.startup_validator import StartupValidator
+11 -53
View File
@@ -52,18 +52,11 @@ class PluginHealthTracker:
"""Get cache key for plugin health data.""" """Get cache key for plugin health data."""
return f"plugin_health:{plugin_id}" return f"plugin_health:{plugin_id}"
def _load_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]: def _load_health_state(self, plugin_id: str) -> Dict[str, Any]:
"""Load health state from cache or return defaults. """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) cache_key = self._get_health_key(plugin_id)
cached = self.cache_manager.get( cached = self.cache_manager.get(cache_key, max_age=None)
cache_key, max_age=None, memory_ttl=0 if force_reload else None
)
if cached: if cached:
return cached return cached
@@ -86,17 +79,10 @@ class PluginHealthTracker:
self.cache_manager.set(cache_key, state) # Persist indefinitely self.cache_manager.set(cache_key, state) # Persist indefinitely
self._health_state[plugin_id] = state self._health_state[plugin_id] = state
def get_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]: def get_health_state(self, plugin_id: str) -> Dict[str, Any]:
"""Get current health state for a plugin. """Get current health state for a plugin."""
if plugin_id not in self._health_state:
``force_reload=True`` re-reads the persisted state from the cache, self._health_state[plugin_id] = self._load_health_state(plugin_id)
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] return self._health_state[plugin_id]
def record_success(self, plugin_id: str) -> None: def record_success(self, plugin_id: str) -> None:
@@ -153,28 +139,6 @@ class PluginHealthTracker:
self._save_health_state(plugin_id, state) 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: def should_skip_plugin(self, plugin_id: str) -> bool:
""" """
Check if plugin should be skipped due to circuit breaker. Check if plugin should be skipped due to circuit breaker.
@@ -217,13 +181,9 @@ class PluginHealthTracker:
return False return False
def get_health_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]: def get_health_summary(self, plugin_id: str) -> Dict[str, Any]:
"""Get health summary for a plugin. """Get health summary for a plugin."""
state = self.get_health_state(plugin_id)
``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) total_calls = state.get('total_successes', 0) + state.get('total_failures', 0)
success_rate = 0.0 success_rate = 0.0
@@ -241,8 +201,6 @@ class PluginHealthTracker:
'last_failure_time': state.get('last_failure_time'), 'last_failure_time': state.get('last_failure_time'),
'last_error': state.get('last_error'), 'last_error': state.get('last_error'),
'is_healthy': state.get('circuit_state') == CircuitState.CLOSED.value, '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'), 'circuit_opened_time': state.get('circuit_opened_time'),
'half_open_start_time': state.get('half_open_start_time') 'half_open_start_time': state.get('half_open_start_time')
} }
+73 -180
View File
@@ -5,10 +5,10 @@ Handles plugin module imports, dependency installation, and class instantiation.
Extracted from PluginManager to improve separation of concerns. Extracted from PluginManager to improve separation of concerns.
""" """
import importlib import hashlib
import importlib.metadata
import importlib.util
import json import json
import importlib
import importlib.util
import os import os
import sys import sys
import subprocess import subprocess
@@ -17,101 +17,12 @@ from pathlib import Path
from typing import Dict, Any, Optional, Tuple, Type from typing import Dict, Any, Optional, Tuple, Type
import logging import logging
from packaging.requirements import InvalidRequirement, Requirement
from src.exceptions import PluginError from src.exceptions import PluginError
from src.logging_config import get_logger from src.logging_config import get_logger
from src.common.permission_utils import (
ensure_file_permissions,
def requirements_has_real_deps(requirements_file: str) -> bool: get_plugin_file_mode
""" )
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: class PluginLoader:
@@ -221,14 +132,14 @@ class PluginLoader:
except (json.JSONDecodeError, Exception) as e: except (json.JSONDecodeError, Exception) as e:
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e) self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
continue continue
return None return None
def install_dependencies( def install_dependencies(
self, self,
plugin_dir: Path, plugin_dir: Path,
plugin_id: str, plugin_id: str,
plugins_dir: Path, plugins_dir: Optional[Path] = None,
timeout: int = 300 timeout: int = 300
) -> bool: ) -> bool:
""" """
@@ -237,12 +148,7 @@ class PluginLoader:
Args: Args:
plugin_dir: Plugin directory path plugin_dir: Plugin directory path
plugin_id: Plugin identifier 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 timeout: Installation timeout in seconds
Returns: Returns:
@@ -254,42 +160,59 @@ class PluginLoader:
# Resolve to a canonical absolute path (normalises .. and symlinks) # Resolve to a canonical absolute path (normalises .. and symlinks)
plugin_dir_real = os.path.realpath(str(plugin_dir)) 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)
# Match the requested directory against an entry actually enumerated if plugins_dir is not None:
# from the trusted plugins_dir, and build the path from that entry -- # Reconstruct the plugin path from a trusted base + a sanitised
# not from requested_name. A name that came out of os.scandir() on a # directory name. os.path.basename() is CodeQL's recognised
# trusted root carries no taint regardless of what the caller asked # py/path-injection sanitiser: it strips all directory components
# for, so this is a real containment guarantee (an allowlist check # so the result cannot contain traversal sequences. Joining it
# against a trusted source), not a string-sanitisation of untrusted # with the resolved, trusted plugins_dir produces a path that
# input that a static analyzer has to trust blindly. # CodeQL considers untainted.
matched_name = find_trusted_subdir(plugins_dir_real, requested_name) plugins_dir_real = os.path.realpath(str(plugins_dir))
if matched_name is None: safe_dir_name = os.path.basename(plugin_dir_real)
self.logger.error( if not safe_dir_name:
"Plugin directory for %s not found inside plugins dir", plugin_id self.logger.error("Could not determine plugin directory name for %s", plugin_id)
) return False
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
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt") 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): if not os.path.isfile(requirements_file):
return True # No dependencies needed return True # No dependencies needed
if not requirements_has_real_deps(requirements_file): try:
self.logger.debug( with open(requirements_file, 'rb') as fh:
"requirements.txt for %s has no real dependencies (comments/blank only), skipping pip", current_hash = hashlib.sha256(fh.read()).hexdigest()
plugin_id except OSError as e:
) self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
return True return False
if requirements_are_satisfied(requirements_file): # Skip if requirements.txt hasn't changed since last install
self.logger.debug( if os.path.isfile(marker_file):
"Dependencies for %s already satisfied in current environment, skipping pip", try:
plugin_id with open(marker_file, 'r', encoding='utf-8') as fh:
) stored_hash = fh.read().strip()
return True except OSError as e:
self.logger.warning(
"Could not read dependency marker for %s (%s), will reinstall dependencies",
plugin_id, e
)
else:
if stored_hash == current_hash:
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
return True
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
try: try:
self.logger.info("Installing dependencies for plugin %s...", plugin_id) self.logger.info("Installing dependencies for plugin %s...", plugin_id)
@@ -302,54 +225,32 @@ class PluginLoader:
) )
if result.returncode == 0: 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) self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True return True
else: else:
stderr = result.stderr or "" stderr = result.stderr or ""
# uninstall-no-record-file means a system-managed copy of a package # uninstall-no-record-file means the package is already present at the
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in # system level (e.g. installed via dnf/apt without a pip RECORD file).
# the way of the version this requirements.txt pins. Retry with # pip can't replace it, but it IS installed — write the marker so we
# --ignore-installed so pip lays the pinned version down alongside # don't retry on every restart.
# 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: if "uninstall-no-record-file" in stderr:
self.logger.warning( self.logger.warning(
"Dependencies for %s conflict with a system-managed package " "Dependencies for %s include system-managed packages (no pip RECORD). "
"(no pip RECORD); retrying with --ignore-installed: %s", "Assuming they are satisfied: %s",
plugin_id, stderr.strip() 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: try:
# sys.executable is this process's own interpreter (not with open(marker_file, 'w', encoding='utf-8') as fh:
# attacker-influenced), and requirements_file is a path fh.write(current_hash)
# built internally by find_plugin_directory, never raw ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
# external input. except OSError as marker_err:
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
[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 return True
self.logger.warning( self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s", "Dependency installation returned non-zero exit code for %s: %s",
@@ -717,14 +618,6 @@ class PluginLoader:
""" """
# Install dependencies if needed # Install dependencies if needed
if install_deps: 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): if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
raise PluginError( raise PluginError(
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}", f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
+89 -76
View File
@@ -9,9 +9,9 @@ API Version: 1.0.0
import json import json
import sys import sys
import subprocess
import time import time
import threading import threading
import types
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
import logging import logging
@@ -177,6 +177,90 @@ class PluginManager:
return plugin_ids 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: def load_plugin(self, plugin_id: str) -> bool:
""" """
Load a plugin by ID. Load a plugin by ID.
@@ -306,15 +390,7 @@ class PluginManager:
self.logger.error("Error validating plugin %s config: %s", plugin_id, e, exc_info=True) 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) self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False 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 # Store plugin instance
self.plugins[plugin_id] = plugin_instance self.plugins[plugin_id] = plugin_instance
self.plugin_last_update[plugin_id] = 0.0 self.plugin_last_update[plugin_id] = 0.0
@@ -343,59 +419,6 @@ class PluginManager:
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e) self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False 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: def unload_plugin(self, plugin_id: str) -> bool:
""" """
Unload a plugin by ID. Unload a plugin by ID.
@@ -744,18 +767,8 @@ class PluginManager:
# If resource monitor exists, wrap the call # If resource monitor exists, wrap the call
def monitored_update(): def monitored_update():
self.resource_monitor.monitor_call(plugin_id, plugin_instance.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( success = self.plugin_executor.execute_update(
types.SimpleNamespace(update=monitored_update), type('obj', (object,), {'update': monitored_update})(),
plugin_id plugin_id
) )
else: else:
@@ -823,7 +836,7 @@ class PluginManager:
# Get health tracker metrics if available # Get health tracker metrics if available
if self.health_tracker: if self.health_tracker:
health_info = self.health_tracker.get_health_summary(plugin_id) health_info = self.health_tracker.get_plugin_health(plugin_id)
plugin_metrics['health'] = health_info plugin_metrics['health'] = health_info
else: else:
plugin_metrics['health'] = {'status': 'unknown'} plugin_metrics['health'] = {'status': 'unknown'}
@@ -848,7 +861,7 @@ class PluginManager:
# Get resource monitor metrics if available # Get resource monitor metrics if available
if self.resource_monitor: if self.resource_monitor:
resource_info = self.resource_monitor.get_metrics_summary(plugin_id) resource_info = self.resource_monitor.get_plugin_metrics(plugin_id)
plugin_metrics['resources'] = resource_info plugin_metrics['resources'] = resource_info
else: else:
plugin_metrics['resources'] = {'status': 'unknown'} plugin_metrics['resources'] = {'status': 'unknown'}
+20 -50
View File
@@ -71,32 +71,17 @@ class PluginResourceMonitor:
self.cache_manager = cache_manager self.cache_manager = cache_manager
self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Resource metrics per plugin # Resource metrics per plugin
self._metrics: Dict[str, ResourceMetrics] = {} self._metrics: Dict[str, ResourceMetrics] = {}
self._limits: Dict[str, ResourceLimits] = {} self._limits: Dict[str, ResourceLimits] = {}
# Thread-local storage for execution tracking # Thread-local storage for execution tracking
self._local = threading.local() self._local = threading.local()
# Lock for thread-safe access # Lock for thread-safe access
self._lock = threading.Lock() 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: if not PSUTIL_AVAILABLE and enable_monitoring:
self.logger.warning( self.logger.warning(
"psutil not available - resource monitoring will be limited to execution time only" "psutil not available - resource monitoring will be limited to execution time only"
@@ -110,21 +95,13 @@ class PluginResourceMonitor:
"""Get cache key for plugin limits.""" """Get cache key for plugin limits."""
return f"plugin_limits:{plugin_id}" return f"plugin_limits:{plugin_id}"
def get_metrics(self, plugin_id: str, force_reload: bool = False) -> ResourceMetrics: def get_metrics(self, plugin_id: str) -> ResourceMetrics:
"""Get current metrics for a plugin. """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: with self._lock:
if force_reload or plugin_id not in self._metrics: if plugin_id not in self._metrics:
# Try to load from cache # Try to load from cache
cache_key = self._get_metrics_key(plugin_id) cache_key = self._get_metrics_key(plugin_id)
cached = self.cache_manager.get( cached = self.cache_manager.get(cache_key, max_age=None)
cache_key, max_age=None, memory_ttl=0 if force_reload else None
)
if cached: if cached:
metrics = ResourceMetrics(**cached) metrics = ResourceMetrics(**cached)
else: else:
@@ -160,24 +137,21 @@ class PluginResourceMonitor:
def _get_process_memory_mb(self) -> float: def _get_process_memory_mb(self) -> float:
"""Get current process memory usage in MB.""" """Get current process memory usage in MB."""
if not self.enable_monitoring or self._process is None: if not self.enable_monitoring:
return 0.0 return 0.0
try: try:
return self._process.memory_info().rss / 1024 / 1024 process = psutil.Process()
return process.memory_info().rss / 1024 / 1024
except Exception: except Exception:
return 0.0 return 0.0
def _get_process_cpu_percent(self) -> float: def _get_process_cpu_percent(self, interval: float = 0.1) -> float:
"""Get current process CPU usage percentage (non-blocking). """Get current process CPU usage percentage."""
if not self.enable_monitoring:
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 return 0.0
try: try:
return self._process.cpu_percent(interval=None) process = psutil.Process()
return process.cpu_percent(interval=interval)
except Exception: except Exception:
return 0.0 return 0.0
@@ -307,13 +281,9 @@ class PluginResourceMonitor:
self.logger.error(error_msg) self.logger.error(error_msg)
raise ResourceLimitExceeded(error_msg) raise ResourceLimitExceeded(error_msg)
def get_metrics_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]: def get_metrics_summary(self, plugin_id: str) -> Dict[str, Any]:
"""Get metrics summary for a plugin. """Get metrics summary for a plugin."""
metrics = self.get_metrics(plugin_id)
``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) limits = self.get_limits(plugin_id)
avg_execution_time = 0.0 avg_execution_time = 0.0
+38 -130
View File
@@ -5,6 +5,7 @@ Handles plugin discovery, installation, updates, and uninstallation
from both the official registry and custom GitHub repositories. from both the official registry and custom GitHub repositories.
""" """
import hashlib
import os import os
import re import re
import json import json
@@ -25,9 +26,6 @@ import logging
from urllib.parse import urlparse from urllib.parse import urlparse
from src.common.permission_utils import sudo_remove_directory, install_requirements_file 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: try:
from jsonschema import Draft7Validator, ValidationError from jsonschema import Draft7Validator, ValidationError
@@ -142,28 +140,9 @@ class PluginStoreManager:
# then get the result from the warm cache (double-checked locking). # then get the result from the warm cache (double-checked locking).
self._registry_fetch_lock = threading.Lock() self._registry_fetch_lock = threading.Lock()
# Per-plugin locks for _reinstall_with_rollback: the web UI runs
# Flask with threaded=True, so two overlapping requests for the
# same plugin_id (double-click, two browser tabs) would otherwise
# both rename the same directory aside — one succeeds, and the
# loser can end up renaming the winner's in-progress install aside
# mid-download, stealing its own rollback safety net. Keyed by
# plugin_id so unrelated plugins still update concurrently.
self._reinstall_locks: Dict[str, threading.Lock] = {}
self._reinstall_locks_guard = threading.Lock()
# Ensure plugins directory exists # Ensure plugins directory exists
self.plugins_dir.mkdir(exist_ok=True) self.plugins_dir.mkdir(exist_ok=True)
def _get_reinstall_lock(self, plugin_id: str) -> threading.Lock:
"""Lazily create (or fetch) the per-plugin reinstall lock."""
with self._reinstall_locks_guard:
lock = self._reinstall_locks.get(plugin_id)
if lock is None:
lock = threading.Lock()
self._reinstall_locks[plugin_id] = lock
return lock
def _record_cache_backoff(self, cache_dict: Dict, cache_key: str, def _record_cache_backoff(self, cache_dict: Dict, cache_key: str,
cache_timeout: int, payload: Any) -> None: cache_timeout: int, payload: Any) -> None:
"""Bump a cache entry's timestamp so subsequent lookups hit the """Bump a cache entry's timestamp so subsequent lookups hit the
@@ -1921,45 +1900,19 @@ class PluginStoreManager:
def _install_dependencies(self, plugin_path: Path) -> bool: def _install_dependencies(self, plugin_path: Path) -> bool:
""" """
Install Python dependencies from requirements.txt. Install Python dependencies from requirements.txt.
Args: Args:
plugin_path: Path to plugin directory plugin_path: Path to plugin directory
Returns: Returns:
True if successful or no requirements file True if successful or no requirements file
""" """
# Reconstruct the plugin path from the trusted self.plugins_dir base + requirements_file = plugin_path / "requirements.txt"
# 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(): if not requirements_file.exists():
self.logger.debug(f"No requirements.txt found in {plugin_path.name}") self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
return True 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: try:
self.logger.info(f"Installing dependencies for {plugin_path.name}") self.logger.info(f"Installing dependencies for {plugin_path.name}")
# Routed through the shared root-visible installer (same one the # Routed through the shared root-visible installer (same one the
@@ -1976,6 +1929,12 @@ class PluginStoreManager:
) )
return False return False
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}") 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 return True
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -2282,74 +2241,6 @@ class PluginStoreManager:
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}") self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
return False return False
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
"""Replace an installed plugin with a fresh install, atomically.
The old install is renamed aside (not deleted) until the new install
succeeds, then removed; on ANY install failure the old directory is
restored. This is the difference between a failed update and a
destroyed plugin: the previous delete-then-install flow permanently
removed plugins whenever the download failed mid-update (seen in the
field during the monorepo migration on a Pi with broken DNS every
old-remote plugin was deleted and none could be re-downloaded).
The aside name embeds '.standalone-backup-' so plugin discovery
(plugin_manager._scan_directory_for_plugins) ignores it even though
it still contains a manifest.json.
Held for the whole operation under a per-plugin_id lock: two
overlapping requests for the same plugin (double-click, two
browser tabs the web UI runs Flask with threaded=True) must not
interleave their renames, or the second could steal the first's
rollback safety net mid-install. Other plugin_ids are unaffected.
"""
with self._get_reinstall_lock(plugin_id):
backup_path = plugin_path.with_name(
f"{plugin_path.name}.standalone-backup-migrating")
# A stale aside from a previous crash would block the rename
if backup_path.exists():
if not self._safe_remove_directory(backup_path):
self.logger.error(
f"Could not clear stale backup for {plugin_id} at "
f"{backup_path}; leaving old install in place")
return False
try:
plugin_path.rename(backup_path)
except OSError as e:
self.logger.error(
f"Could not set aside old plugin directory for {plugin_id}: {e}")
return False
try:
installed = self.install_plugin(plugin_id)
except Exception as e:
self.logger.error(f"Reinstall of {plugin_id} raised: {e}")
installed = False
if installed:
if not self._safe_remove_directory(backup_path):
self.logger.warning(
f"Update of {plugin_id} succeeded but the old backup "
f"at {backup_path} could not be removed; it will be "
f"cleared on the next update")
return True
# Install failed (bad network, registry error...) — put the old
# version back so the user still has a working plugin.
self.logger.error(
f"Reinstall of {plugin_id} failed; restoring previous version")
try:
if plugin_path.exists():
# partial download debris from the failed install
self._safe_remove_directory(plugin_path)
backup_path.rename(plugin_path)
self.logger.info(f"Restored previous install of {plugin_id}")
except OSError as e:
self.logger.error(
f"CRITICAL: could not restore {plugin_id} from {backup_path}: {e}. "
f"The previous install is preserved there — rename it back manually.")
return False
def update_plugin(self, plugin_id: str) -> bool: def update_plugin(self, plugin_id: str) -> bool:
""" """
Update a plugin to the latest commit on its upstream branch. Update a plugin to the latest commit on its upstream branch.
@@ -2412,7 +2303,10 @@ class PluginStoreManager:
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). " f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
f"Reinstalling from registry to migrate to new source." f"Reinstalling from registry to migrate to new source."
) )
return self._reinstall_with_rollback(resolved_id, plugin_path) if not self._safe_remove_directory(plugin_path):
self.logger.error(f"Failed to remove old plugin directory for {resolved_id}")
return False
return self.install_plugin(resolved_id)
# Check if already up to date # Check if already up to date
if remote_sha and local_sha and remote_sha.startswith(local_sha): if remote_sha and local_sha and remote_sha.startswith(local_sha):
@@ -2538,6 +2432,19 @@ class PluginStoreManager:
file_path = line[3:].strip() file_path = line[3:].strip()
untracked_files.append(file_path) 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 # Check for tracked file changes
status_result = subprocess.run( status_result = subprocess.run(
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'], ['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
@@ -2548,9 +2455,10 @@ class PluginStoreManager:
) )
has_changes = bool(status_result.stdout.strip()) has_changes = bool(status_result.stdout.strip())
# If there are untracked files, stash them # If there are remaining untracked files (not safe to remove), stash them
if untracked_files: remaining_untracked = [f for f in untracked_files if f not in removed_files]
self.logger.info(f"Found {len(untracked_files)} untracked files in {plugin_id}, will stash them") if remaining_untracked:
self.logger.info(f"Found {len(remaining_untracked)} untracked files in {plugin_id}, will stash them")
has_changes = True has_changes = True
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
# If status check times out, assume there might be changes and proceed # If status check times out, assume there might be changes and proceed
@@ -2716,11 +2624,11 @@ class PluginStoreManager:
# Plugin is not a git repo but is in registry and has a newer version - reinstall # Plugin is not a git repo but is in registry and has a newer version - reinstall
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})") self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
# Reinstall with the old version kept aside until the new # Remove directory and reinstall fresh
# download succeeds — this is the path every routine store if not self._safe_remove_directory(plugin_path):
# update takes, and a mid-update network failure must not self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
# destroy the user's plugin. return False
return self._reinstall_with_rollback(registry_id, plugin_path) return self.install_plugin(registry_id)
except Exception as e: except Exception as e:
import traceback import traceback
@@ -454,18 +454,6 @@ class VisualTestDisplayManager:
"""Check if display is currently scrolling.""" """Check if display is currently scrolling."""
return self._scrolling_state['is_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 # Utility methods
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+1 -90
View File
@@ -1883,96 +1883,7 @@ class WiFiManager:
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts") logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
return False 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]: def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
""" """
Enable access point mode Enable access point mode
-10
View File
@@ -172,16 +172,6 @@ class TestVisualDisplayManager:
vdm.set_scrolling_state(False) vdm.set_scrolling_state(False)
assert vdm.is_currently_scrolling() is 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): def test_format_date_with_ordinal(self):
from datetime import datetime from datetime import datetime
vdm = VisualTestDisplayManager(width=128, height=32) vdm = VisualTestDisplayManager(width=128, height=32)
+1 -14
View File
@@ -279,23 +279,10 @@ class TestDiskCache:
"""Test getting expired cache entry.""" """Test getting expired cache entry."""
cache = DiskCache(cache_dir=str(tmp_path)) cache = DiskCache(cache_dir=str(tmp_path))
cache.set("test_key", {"data": "value"}) cache.set("test_key", {"data": "value"})
# Get with max_age=0 to force expiration # Get with max_age=0 to force expiration
result = cache.get("test_key", max_age=0) result = cache.get("test_key", max_age=0)
assert result is None 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): def test_get_nonexistent(self, tmp_path):
"""Test getting non-existent key.""" """Test getting non-existent key."""
-13
View File
@@ -391,16 +391,3 @@ class TestDisplayControllerSchedule:
controller._check_schedule() controller._check_schedule()
assert controller.is_display_active is False 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)
-83
View File
@@ -1,83 +0,0 @@
"""
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
-93
View File
@@ -1,93 +0,0 @@
"""
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)
+6 -128
View File
@@ -4,8 +4,6 @@ Tests for PluginLoader.
Tests plugin directory discovery, module loading, and class instantiation. Tests plugin directory discovery, module loading, and class instantiation.
""" """
import subprocess
import pytest import pytest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from src.plugin_system.plugin_loader import PluginLoader from src.plugin_system.plugin_loader import PluginLoader
@@ -193,7 +191,7 @@ class TestPluginLoader:
mock_subprocess.return_value = MagicMock(returncode=0) mock_subprocess.return_value = MagicMock(returncode=0)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir) result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
assert result is True assert result is True
mock_subprocess.assert_called_once() mock_subprocess.assert_called_once()
@@ -204,7 +202,7 @@ class TestPluginLoader:
plugin_dir = tmp_plugins_dir / "test_plugin" plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir() plugin_dir.mkdir()
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir) result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
assert result is True assert result is True
mock_subprocess.assert_not_called() mock_subprocess.assert_not_called()
@@ -216,129 +214,9 @@ class TestPluginLoader:
plugin_dir.mkdir() plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt" requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("package1==1.0.0\n") requirements_file.write_text("package1==1.0.0\n")
mock_subprocess.return_value = MagicMock(returncode=1) mock_subprocess.return_value = MagicMock(returncode=1)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir) result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
assert result is False 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()
-79
View File
@@ -1,79 +0,0 @@
"""
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 -53
View File
@@ -3,7 +3,6 @@ from unittest.mock import MagicMock, patch
from pathlib import Path from pathlib import Path
from src.plugin_system.plugin_manager import PluginManager from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.plugin_state import PluginState from src.plugin_system.plugin_state import PluginState
from src.plugin_system.resource_monitor import PluginResourceMonitor
class TestPluginManager: class TestPluginManager:
"""Test PluginManager functionality.""" """Test PluginManager functionality."""
@@ -75,67 +74,18 @@ class TestPluginManager:
# No manifest in pm.plugin_manifests # No manifest in pm.plugin_manifests
result = pm.load_plugin("non_existent_plugin") result = pm.load_plugin("non_existent_plugin")
assert result is False assert result is False
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR 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: class TestPluginLoader:
"""Test PluginLoader functionality.""" """Test PluginLoader functionality."""
def test_dependency_check(self): def test_dependency_check(self):
"""Test dependency checking logic.""" """Test dependency checking logic."""
# Covered by test_plugin_loader.py's install_dependencies tests, # This would test _check_dependencies_installed and _install_plugin_dependencies
# which exercise requirements_has_real_deps/requirements_are_satisfied # which requires mocking subprocess calls and file operations
# and the pip subprocess fallback.
class TestPluginExecutor: class TestPluginExecutor:
-129
View File
@@ -1,129 +0,0 @@
"""
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)
-171
View File
@@ -1,171 +0,0 @@
"""Tests for atomic plugin updates (store_manager._reinstall_with_rollback).
Regression for a field data-loss incident: update_plugin's reinstall paths
(monorepo migration AND routine archive updates) deleted the installed
plugin BEFORE downloading its replacement a mid-update network failure
permanently destroyed the plugin. Seen live: a Pi with broken DNS lost 12
plugins from one update pass.
"""
import json
import os
import sys
import threading
import time
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.plugin_system.store_manager import PluginStoreManager # noqa: E402
PLUGIN_ID = "rollback-test-plugin"
@pytest.fixture
def store(tmp_path):
mgr = PluginStoreManager(plugins_dir=str(tmp_path))
plugin_dir = tmp_path / PLUGIN_ID
plugin_dir.mkdir()
(plugin_dir / "manifest.json").write_text(json.dumps(
{"id": PLUGIN_ID, "name": "Rollback Test", "version": "1.0.0"}))
(plugin_dir / "manager.py").write_text("# old version marker\n")
return mgr, plugin_dir
class TestReinstallWithRollback:
def test_failed_install_restores_old_version(self, store):
"""The whole point: a failed download must leave the old install."""
mgr, plugin_dir = store
with patch.object(mgr, "install_plugin", return_value=False):
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
assert ok is False
assert plugin_dir.exists()
assert "old version marker" in (plugin_dir / "manager.py").read_text()
# no aside debris left behind
leftovers = [p for p in plugin_dir.parent.iterdir()
if "standalone-backup" in p.name]
assert leftovers == []
def test_install_exception_restores_old_version(self, store):
mgr, plugin_dir = store
with patch.object(mgr, "install_plugin",
side_effect=RuntimeError("network down")):
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
assert ok is False
assert plugin_dir.exists()
assert "old version marker" in (plugin_dir / "manager.py").read_text()
def test_successful_install_removes_aside(self, store):
mgr, plugin_dir = store
def fake_install(plugin_id):
new_dir = plugin_dir # same path, new content
new_dir.mkdir(exist_ok=True)
(new_dir / "manager.py").write_text("# new version\n")
(new_dir / "manifest.json").write_text(json.dumps(
{"id": PLUGIN_ID, "name": "Rollback Test", "version": "2.0.0"}))
return True
with patch.object(mgr, "install_plugin", side_effect=fake_install):
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
assert ok is True
assert "new version" in (plugin_dir / "manager.py").read_text()
leftovers = [p for p in plugin_dir.parent.iterdir()
if "standalone-backup" in p.name]
assert leftovers == []
def test_partial_download_debris_is_replaced_by_old_version(self, store):
"""A failed install that left a partial directory must still roll back."""
mgr, plugin_dir = store
def fake_partial_install(plugin_id):
plugin_dir.mkdir(exist_ok=True)
(plugin_dir / "half-downloaded.tmp").write_text("junk")
return False
with patch.object(mgr, "install_plugin", side_effect=fake_partial_install):
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
assert ok is False
assert "old version marker" in (plugin_dir / "manager.py").read_text()
assert not (plugin_dir / "half-downloaded.tmp").exists()
def test_stale_aside_from_previous_crash_is_cleared(self, store):
mgr, plugin_dir = store
stale = plugin_dir.parent / f"{PLUGIN_ID}.standalone-backup-migrating"
stale.mkdir()
(stale / "old.txt").write_text("stale")
with patch.object(mgr, "install_plugin", return_value=False) as mock_install:
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
# The reinstall itself still fails (mocked) and the old install is
# restored, but the stale aside must not have survived — otherwise
# it would have blocked this run's own rename (or a future one).
assert not stale.exists()
mock_install.assert_called_once_with(PLUGIN_ID)
assert ok is False
assert plugin_dir.exists()
assert "old version marker" in (plugin_dir / "manager.py").read_text()
def test_concurrent_updates_for_same_plugin_are_serialized(self, store):
"""Two overlapping requests for the same plugin_id (double-click,
two browser tabs the web UI runs Flask with threaded=True) must
not interleave: the loser must wait for the winner to finish
rather than renaming the winner's in-progress install aside and
stealing its rollback safety net."""
mgr, plugin_dir = store
active = 0
max_active = 0
guard = threading.Lock()
def fake_install(plugin_id):
nonlocal active, max_active
with guard:
active += 1
max_active = max(max_active, active)
time.sleep(0.05)
plugin_dir.mkdir(exist_ok=True)
(plugin_dir / "manager.py").write_text("# new version\n")
(plugin_dir / "manifest.json").write_text(json.dumps(
{"id": PLUGIN_ID, "name": "Rollback Test", "version": "2.0.0"}))
with guard:
active -= 1
return True
results = []
def worker():
results.append(mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir))
with patch.object(mgr, "install_plugin", side_effect=fake_install):
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
assert max_active == 1, "install_plugin ran concurrently for the same plugin_id"
assert results == [True, True]
assert plugin_dir.exists()
assert "new version" in (plugin_dir / "manager.py").read_text()
leftovers = [p for p in plugin_dir.parent.iterdir()
if "standalone-backup" in p.name]
assert leftovers == []
def test_aside_name_is_invisible_to_discovery(self, store, tmp_path):
"""The aside still contains a manifest.json — discovery must skip it
(relies on the existing '.standalone-backup-' exclusion)."""
mgr, plugin_dir = store
from src.plugin_system.plugin_manager import PluginManager
aside = plugin_dir.parent / f"{PLUGIN_ID}.standalone-backup-migrating"
plugin_dir.rename(aside)
pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
display_manager=None, cache_manager=None)
found = pm._scan_directory_for_plugins(Path(tmp_path))
assert PLUGIN_ID not in found
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
-51
View File
@@ -759,54 +759,3 @@ class TestDottedKeyNormalization:
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams') teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
assert isinstance(teams, list), f"Expected list, got: {type(teams)}" assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
assert teams == [], f"Expected empty default list, got: {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
-16
View File
@@ -160,22 +160,6 @@ api_v3.health_monitor = health_monitor
from src.cache_manager import CacheManager from src.cache_manager import CacheManager
api_v3.cache_manager = 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(pages_v3, url_prefix='/v3')
app.register_blueprint(api_v3, url_prefix='/api/v3') app.register_blueprint(api_v3, url_prefix='/api/v3')
+4 -140
View File
@@ -2073,18 +2073,6 @@ def get_installed_plugins():
return None return None
def _build_plugin_entry_inner(plugin_info, plugin_id): 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 # 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" manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
if manifest_path.exists(): if manifest_path.exists():
@@ -2166,8 +2154,6 @@ def get_installed_plugins():
'enabled': enabled, 'enabled': enabled,
'verified': verified, 'verified': verified,
'loaded': plugin_info.get('loaded', False), 'loaded': plugin_info.get('loaded', False),
'state': plugin_state,
'error_info': plugin_error_info,
'last_updated': last_updated, 'last_updated': last_updated,
'last_commit': last_commit, 'last_commit': last_commit,
'last_commit_message': last_commit_message, 'last_commit_message': last_commit_message,
@@ -2187,31 +2173,6 @@ def get_installed_plugins():
logger.error('Error in get_installed_plugins', exc_info=True) logger.error('Error in get_installed_plugins', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 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']) @api_v3.route('/plugins/health', methods=['GET'])
def get_plugin_health(): def get_plugin_health():
"""Get health metrics for all plugins""" """Get health metrics for all plugins"""
@@ -2227,23 +2188,8 @@ def get_plugin_health():
'message': 'Health tracking not available' 'message': 'Health tracking not available'
}) })
tracker = api_v3.plugin_manager.health_tracker # Get health summaries for all plugins
# Build per-plugin summaries by ID so persisted (cross-process) health health_summaries = api_v3.plugin_manager.health_tracker.get_all_health_summaries()
# 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({ return jsonify({
'status': 'success', 'status': 'success',
@@ -2318,22 +2264,8 @@ def get_plugin_metrics():
'message': 'Resource monitoring not available' 'message': 'Resource monitoring not available'
}) })
monitor = api_v3.plugin_manager.resource_monitor # Get metrics summaries for all plugins
# Build per-plugin summaries by ID so persisted (cross-process) metrics metrics_summaries = api_v3.plugin_manager.resource_monitor.get_all_metrics_summaries()
# 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({ return jsonify({
'status': 'success', 'status': 'success',
@@ -7252,74 +7184,6 @@ def set_auto_enable_ap_mode():
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details'
}), 500 }), 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']) @api_v3.route('/cache/list', methods=['GET'])
def list_cache_files(): def list_cache_files():
"""List all cache files with metadata""" """List all cache files with metadata"""
@@ -340,25 +340,11 @@ const PluginAPI = {
* @returns {Promise<Object>} Health data * @returns {Promise<Object>} Health data
*/ */
async getPluginHealth(pluginId = null) { async getPluginHealth(pluginId = null) {
const endpoint = pluginId const endpoint = pluginId
? `/plugins/health/${pluginId}` ? `/plugins/health/${pluginId}`
: '/plugins/health'; : '/plugins/health';
const response = await this.request(endpoint); const response = await this.request(endpoint);
return response.data || {}; 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 || {};
} }
}; };
+6 -23
View File
@@ -134,15 +134,10 @@
function openResults() { function openResults() {
resultsBox.classList.remove('hidden'); 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'); if (input) input.setAttribute('aria-expanded', 'true');
} }
function closeResults() { function closeResults() {
resultsBox.classList.add('hidden'); resultsBox.classList.add('hidden');
resultsBox.style.display = 'none';
activeIndex = -1; activeIndex = -1;
if (input) { if (input) {
input.setAttribute('aria-expanded', 'false'); input.setAttribute('aria-expanded', 'false');
@@ -300,12 +295,7 @@
input.addEventListener('input', debounce(function () { input.addEventListener('input', debounce(function () {
var q = input.value; var q = input.value;
if (!q.trim()) { closeResults(); return; } if (!q.trim()) { closeResults(); return; }
// Focus may have left during the debounce (typed then clicked away); buildIndex().then(function () { renderResults(search(q)); });
// 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)); }, 200));
input.addEventListener('keydown', function (e) { input.addEventListener('keydown', function (e) {
@@ -339,18 +329,11 @@
if (chosen) navigateToSetting(chosen); if (chosen) navigateToSetting(chosen);
}); });
// Close when a click/tap lands outside the search widget. Capture phase document.addEventListener('click', function (e) {
// (the `true`) runs on the way DOWN, before any bubbling stopPropagation if (!input) return;
// from Alpine/HTMX/widget handlers can swallow the event — a plain if (e.target === input || resultsBox.contains(e.target)) return;
// bubble-phase document listener was being eaten and never closing us. closeResults();
// 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 // Reliable dismiss: close shortly after focus leaves the box. Result
// selection uses mousedown + preventDefault (focus stays on the input), // selection uses mousedown + preventDefault (focus stays on the input),
@@ -1,23 +1,5 @@
<div class="space-y-6" id="tools-root"> <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 --> <!-- Git & Updates -->
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
@@ -141,46 +123,6 @@
</div> </div>
</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 --> <!-- Services -->
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
@@ -215,100 +157,6 @@
</div> </div>
</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> </div>
<script> <script>
@@ -549,290 +397,7 @@
}); });
} }
// ── 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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. // Load on first render; HTMX will have already swapped us in by this point.
loadGitInfo(); 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> </script>