Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f40e6f3127 | ||
|
|
453d46563f | ||
|
|
06ea4aa496 | ||
|
|
344f64ebba | ||
|
|
bafa2048a0 |
@@ -1,44 +0,0 @@
|
|||||||
name: Claude Code Review
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, ready_for_review, reopened]
|
|
||||||
# Optional: Only run on specific file changes
|
|
||||||
# paths:
|
|
||||||
# - "src/**/*.ts"
|
|
||||||
# - "src/**/*.tsx"
|
|
||||||
# - "src/**/*.js"
|
|
||||||
# - "src/**/*.jsx"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude-review:
|
|
||||||
# Optional: Filter by PR author
|
|
||||||
# if: |
|
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
|
||||||
id: claude-review
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
|
||||||
plugins: 'code-review@claude-code-plugins'
|
|
||||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 111 KiB |
@@ -227,8 +227,8 @@ wait_for_apt_lock() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
apt_update() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 update; }
|
apt_update() { wait_for_apt_lock; retry apt update; }
|
||||||
apt_install() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 install -y "$@"; }
|
apt_install() { wait_for_apt_lock; retry apt install -y "$@"; }
|
||||||
apt_remove() { apt-get remove -y "$@" || true; }
|
apt_remove() { apt-get remove -y "$@" || true; }
|
||||||
|
|
||||||
check_network() {
|
check_network() {
|
||||||
@@ -726,11 +726,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
|
|
||||||
if command -v timeout >/dev/null 2>&1; then
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
# Use timeout if available (10 minutes = 600 seconds)
|
# Use timeout if available (10 minutes = 600 seconds)
|
||||||
# --ignore-installed: apt-managed packages (e.g. python3-requests)
|
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||||
# ship no pip RECORD file, so upgrading them would otherwise abort
|
|
||||||
# with "uninstall-no-record-file"; this lays the new version down
|
|
||||||
# alongside instead of trying to uninstall the apt copy first.
|
|
||||||
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
|
||||||
INSTALL_SUCCESS=true
|
INSTALL_SUCCESS=true
|
||||||
else
|
else
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
@@ -738,7 +734,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
echo "✗ Timeout (10 minutes) installing: $line"
|
echo "✗ Timeout (10 minutes) installing: $line"
|
||||||
echo " This package may require building from source, which can be slow on Raspberry Pi."
|
echo " This package may require building from source, which can be slow on Raspberry Pi."
|
||||||
echo " You can try installing it manually later with:"
|
echo " You can try installing it manually later with:"
|
||||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose '$line'"
|
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose '$line'"
|
||||||
else
|
else
|
||||||
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
|
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
|
||||||
fi
|
fi
|
||||||
@@ -746,7 +742,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
else
|
else
|
||||||
# No timeout command available, install without timeout
|
# No timeout command available, install without timeout
|
||||||
echo " Note: timeout command not available, installation may take a while..."
|
echo " Note: timeout command not available, installation may take a while..."
|
||||||
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||||
INSTALL_SUCCESS=true
|
INSTALL_SUCCESS=true
|
||||||
else
|
else
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
@@ -798,7 +794,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
echo " 1. Ensure you have enough disk space: df -h"
|
echo " 1. Ensure you have enough disk space: df -h"
|
||||||
echo " 2. Check available memory: free -h"
|
echo " 2. Check available memory: free -h"
|
||||||
echo " 3. Try installing failed packages individually with verbose output:"
|
echo " 3. Try installing failed packages individually with verbose output:"
|
||||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose <package>"
|
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose <package>"
|
||||||
echo " 4. For packages that build from source (like numpy), consider:"
|
echo " 4. For packages that build from source (like numpy), consider:"
|
||||||
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
|
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
|
||||||
echo " - Or installing via apt if available: sudo apt install python3-<package>"
|
echo " - Or installing via apt if available: sudo apt install python3-<package>"
|
||||||
@@ -820,10 +816,7 @@ echo ""
|
|||||||
# Install web interface dependencies
|
# Install web interface dependencies
|
||||||
echo "Installing web interface dependencies..."
|
echo "Installing web interface dependencies..."
|
||||||
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
|
||||||
# --ignore-installed: apt-managed packages (e.g. python3-requests) ship no
|
if python3 -m pip install --break-system-packages --prefer-binary -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
||||||
# pip RECORD file, so upgrading them to the version pinned here would
|
|
||||||
# otherwise abort the whole install with "uninstall-no-record-file".
|
|
||||||
if python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
|
||||||
echo "✓ Web interface dependencies installed"
|
echo "✓ Web interface dependencies installed"
|
||||||
# Create marker file to indicate dependencies are installed
|
# Create marker file to indicate dependencies are installed
|
||||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||||
@@ -864,30 +857,24 @@ if [ "$_SKIP_BUILD" = "1" ]; then
|
|||||||
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||||
else
|
else
|
||||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||||
# Wrapper used with retry(): removes any partial clone dir before each attempt
|
|
||||||
# so git clone doesn't fail with "destination path already exists".
|
|
||||||
_clone_rpi_rgb() {
|
|
||||||
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
|
||||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
|
||||||
}
|
|
||||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
||||||
cd "$PROJECT_ROOT_DIR"
|
cd "$PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
# Try to initialize submodule if .gitmodules exists
|
# Try to initialize submodule if .gitmodules exists
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||||
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||||
retry _clone_rpi_rgb
|
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Fallback: clone directly if submodule not configured
|
# Fallback: clone directly if submodule not configured
|
||||||
echo "Submodule not configured, cloning directly from GitHub..."
|
echo "Submodule not configured, cloning directly from GitHub..."
|
||||||
retry _clone_rpi_rgb
|
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build and install rpi-rgb-led-matrix Python bindings
|
# Build and install rpi-rgb-led-matrix Python bindings
|
||||||
if [ -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
# Check if submodule is properly initialized (not empty)
|
# Check if submodule is properly initialized (not empty)
|
||||||
@@ -898,7 +885,7 @@ else
|
|||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||||
else
|
else
|
||||||
retry _clone_rpi_rgb
|
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -984,9 +971,7 @@ else
|
|||||||
else
|
else
|
||||||
echo "Using pip to install dependencies..."
|
echo "Using pip to install dependencies..."
|
||||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||||
# --ignore-installed: see the Step 5 web_interface/requirements.txt
|
python3 -m pip install --break-system-packages --prefer-binary -r requirements_web_v2.txt
|
||||||
# install above — same apt/pip RECORD-file conflict applies here.
|
|
||||||
python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r requirements_web_v2.txt
|
|
||||||
else
|
else
|
||||||
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# safe_pip_install.sh — Install a requirements.txt as root after validating
|
|
||||||
# that the resolved path is the project's own requirements.txt or a plugin's
|
|
||||||
# requirements.txt under plugin-repos/ or plugins/.
|
|
||||||
#
|
|
||||||
# This script is intended to be called via sudo from the web interface, so
|
|
||||||
# that packages a plugin declares end up visible to ledmatrix.service (which
|
|
||||||
# runs as root) rather than only to whichever non-root user runs the web
|
|
||||||
# interface. Plugin code already runs as root once loaded, so installing its
|
|
||||||
# declared dependencies as root is not a new trust boundary.
|
|
||||||
#
|
|
||||||
# Usage: safe_pip_install.sh <requirements_txt_path>
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ $# -ne 1 ]; then
|
|
||||||
echo "Usage: $0 <requirements_txt_path>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TARGET="$1"
|
|
||||||
|
|
||||||
# Determine the project root (parent of scripts/fix_perms/)
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
|
|
||||||
# Allowed locations (resolved, no trailing slash):
|
|
||||||
# - the project's own requirements.txt
|
|
||||||
# - any requirements.txt under plugin-repos/ or plugins/
|
|
||||||
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
|
|
||||||
ALLOWED_BASES=(
|
|
||||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
|
|
||||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve the target path (follow symlinks); works even if it doesn't exist.
|
|
||||||
RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")"
|
|
||||||
|
|
||||||
# Must be named requirements.txt — never install from an arbitrary file.
|
|
||||||
if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
|
|
||||||
echo "DENIED: $RESOLVED_TARGET is not a requirements.txt file" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
ALLOWED=false
|
|
||||||
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
|
|
||||||
ALLOWED=true
|
|
||||||
else
|
|
||||||
for BASE in "${ALLOWED_BASES[@]}"; do
|
|
||||||
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
|
|
||||||
ALLOWED=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$ALLOWED" = false ]; then
|
|
||||||
echo "DENIED: $RESOLVED_TARGET is not an allowed requirements.txt location" >&2
|
|
||||||
echo "Allowed: $ALLOWED_EXACT, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$RESOLVED_TARGET" ]; then
|
|
||||||
echo "ERROR: $RESOLVED_TARGET does not exist" >&2
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
PYTHON_PATH="$(command -v python3)"
|
|
||||||
# --ignore-installed: root's site-packages often has apt/dpkg-managed copies
|
|
||||||
# of common libraries (requests, urllib3, ...) with no pip RECORD file, which
|
|
||||||
# pip refuses to uninstall in place ("Cannot uninstall: no RECORD file was
|
|
||||||
# found"). This tells pip to install the newer version alongside rather than
|
|
||||||
# aborting the whole requirements.txt install over one such conflict.
|
|
||||||
exec "$PYTHON_PATH" -m pip install --break-system-packages --ignore-installed -r "$RESOLVED_TARGET"
|
|
||||||
@@ -33,7 +33,6 @@ POWEROFF_PATH=$(command -v poweroff) || true
|
|||||||
BASH_PATH=$(command -v bash) || true
|
BASH_PATH=$(command -v bash) || true
|
||||||
JOURNALCTL_PATH=$(command -v journalctl) || true
|
JOURNALCTL_PATH=$(command -v journalctl) || true
|
||||||
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
||||||
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
|
|
||||||
|
|
||||||
# Validate required commands (systemctl, bash, python3 are essential)
|
# Validate required commands (systemctl, bash, python3 are essential)
|
||||||
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
||||||
@@ -49,15 +48,11 @@ if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate helper scripts exist
|
# Validate helper script exists
|
||||||
if [ ! -f "$SAFE_RM_PATH" ]; then
|
if [ ! -f "$SAFE_RM_PATH" ]; then
|
||||||
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
|
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
|
|
||||||
echo "Error: Safe pip install helper not found: $SAFE_PIP_INSTALL_PATH" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Command paths:"
|
echo "Command paths:"
|
||||||
echo " Python: $PYTHON_PATH"
|
echo " Python: $PYTHON_PATH"
|
||||||
@@ -67,7 +62,6 @@ echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
|
|||||||
echo " Bash: $BASH_PATH"
|
echo " Bash: $BASH_PATH"
|
||||||
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
||||||
echo " Safe plugin rm: $SAFE_RM_PATH"
|
echo " Safe plugin rm: $SAFE_RM_PATH"
|
||||||
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
|
|
||||||
|
|
||||||
# Create a temporary sudoers file
|
# Create a temporary sudoers file
|
||||||
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||||
@@ -107,22 +101,13 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Required: python3, bash
|
# Required: python3, bash
|
||||||
# NOTE: display_controller.py/start_display.sh/stop_display.sh live at the
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py"
|
||||||
# project root, not under scripts/install/ (where this script lives) —
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh"
|
||||||
# must use PROJECT_ROOT here, not PROJECT_DIR.
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT/display_controller.py"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/start_display.sh"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/stop_display.sh"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "# Allow web user to remove plugin directories via vetted helper script"
|
echo "# Allow web user to remove plugin directories via vetted helper script"
|
||||||
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
|
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
|
||||||
echo ""
|
|
||||||
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
|
|
||||||
echo "# helper script, so packages are visible to root-run ledmatrix.service"
|
|
||||||
echo "# (not just the web interface's own user). The helper validates the target"
|
|
||||||
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
|
|
||||||
} > "$TEMP_SUDOERS"
|
} > "$TEMP_SUDOERS"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -141,7 +126,6 @@ echo "- Run display_controller.py directly"
|
|||||||
echo "- Execute start_display.sh and stop_display.sh"
|
echo "- Execute start_display.sh and stop_display.sh"
|
||||||
echo "- Reboot and shutdown the system"
|
echo "- Reboot and shutdown the system"
|
||||||
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
||||||
echo "- Install plugin/base requirements.txt as root (so ledmatrix.service can see them)"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Ask for confirmation
|
# Ask for confirmation
|
||||||
@@ -163,13 +147,6 @@ fi
|
|||||||
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
|
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
|
||||||
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
|
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
|
||||||
fi
|
fi
|
||||||
echo "Hardening safe_pip_install.sh ownership..."
|
|
||||||
if ! sudo chown root:root "$SAFE_PIP_INSTALL_PATH"; then
|
|
||||||
echo "Warning: Could not set ownership on $SAFE_PIP_INSTALL_PATH"
|
|
||||||
fi
|
|
||||||
if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
|
|
||||||
echo "Warning: Could not set permissions on $SAFE_PIP_INSTALL_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
||||||
echo "Configuration applied successfully!"
|
echo "Configuration applied successfully!"
|
||||||
@@ -183,7 +160,7 @@ if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
|||||||
echo "✗ systemctl status ledmatrix.service - Failed"
|
echo "✗ systemctl status ledmatrix.service - Failed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
|
if sudo -n test -f "$PROJECT_DIR/start_display.sh"; then
|
||||||
echo "✓ File access test - OK"
|
echo "✓ File access test - OK"
|
||||||
else
|
else
|
||||||
echo "✗ File access test - Failed"
|
echo "✗ File access test - Failed"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import tempfile
|
|||||||
import warnings
|
import warnings
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple
|
|
||||||
|
|
||||||
# How many trailing lines of a failed command's output to keep for the
|
# How many trailing lines of a failed command's output to keep for the
|
||||||
# end-of-run failure summary. Keeps the root cause near the end of the log,
|
# end-of-run failure summary. Keeps the root cause near the end of the log,
|
||||||
@@ -18,7 +17,7 @@ from typing import List, Tuple
|
|||||||
ERROR_TAIL_LINES = 15
|
ERROR_TAIL_LINES = 15
|
||||||
|
|
||||||
|
|
||||||
def _run(cmd: List[str]) -> Tuple[bool, str]:
|
def _run(cmd):
|
||||||
"""Run a command, streaming combined stdout/stderr to a temp file.
|
"""Run a command, streaming combined stdout/stderr to a temp file.
|
||||||
|
|
||||||
Returns (success, output) instead of raising, so callers can report
|
Returns (success, output) instead of raising, so callers can report
|
||||||
@@ -38,7 +37,7 @@ def _run(cmd: List[str]) -> Tuple[bool, str]:
|
|||||||
return result.returncode == 0, '\n'.join(tail)
|
return result.returncode == 0, '\n'.join(tail)
|
||||||
|
|
||||||
|
|
||||||
def install_via_apt(package_name: str) -> Tuple[bool, str]:
|
def install_via_apt(package_name):
|
||||||
"""Try to install a package via apt. Returns (success, output)."""
|
"""Try to install a package via apt. Returns (success, output)."""
|
||||||
# Map pip package names to apt package names
|
# Map pip package names to apt package names
|
||||||
apt_package_map = {
|
apt_package_map = {
|
||||||
@@ -60,7 +59,7 @@ def install_via_apt(package_name: str) -> Tuple[bool, str]:
|
|||||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
||||||
|
|
||||||
print(f"Trying to install {apt_package} via apt...")
|
print(f"Trying to install {apt_package} via apt...")
|
||||||
success, output = _run(['sudo', 'apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', apt_package])
|
success, output = _run(['sudo', 'apt', 'install', '-y', apt_package])
|
||||||
if success:
|
if success:
|
||||||
print(f"Successfully installed {apt_package} via apt")
|
print(f"Successfully installed {apt_package} via apt")
|
||||||
return True, ""
|
return True, ""
|
||||||
@@ -69,7 +68,7 @@ def install_via_apt(package_name: str) -> Tuple[bool, str]:
|
|||||||
return False, output
|
return False, output
|
||||||
|
|
||||||
|
|
||||||
def install_via_pip(package_name: str) -> Tuple[bool, str]:
|
def install_via_pip(package_name):
|
||||||
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
||||||
|
|
||||||
--break-system-packages allows pip to install into the system Python on
|
--break-system-packages allows pip to install into the system Python on
|
||||||
@@ -106,7 +105,7 @@ IMPORT_NAME_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def check_package_installed(package_name: str) -> bool:
|
def check_package_installed(package_name):
|
||||||
"""Check if a package is already installed."""
|
"""Check if a package is already installed."""
|
||||||
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
|
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
|
||||||
# Suppress deprecation warnings when checking if packages are installed
|
# Suppress deprecation warnings when checking if packages are installed
|
||||||
@@ -120,7 +119,7 @@ def check_package_installed(package_name: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None:
|
def print_failure_summary(failed_packages, failure_details):
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
|
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
@@ -209,7 +208,7 @@ def main():
|
|||||||
if setup_py.exists():
|
if setup_py.exists():
|
||||||
# Try installing - use regular install, not editable mode
|
# Try installing - use regular install, not editable mode
|
||||||
# This is optional for web interface and should already be installed in Step 6
|
# This is optional for web interface and should already be installed in Step 6
|
||||||
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
|
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--ignore-installed', str(rgbmatrix_path)])
|
||||||
if ok:
|
if ok:
|
||||||
print("rgbmatrix module installed successfully")
|
print("rgbmatrix module installed successfully")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,34 +8,13 @@ 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
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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
|
||||||
@@ -308,119 +287,3 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
|
|||||||
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
|
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
|
|
||||||
"""
|
|
||||||
Install a requirements.txt file for a plugin (or the project itself).
|
|
||||||
|
|
||||||
Prefers the vetted sudo wrapper (scripts/fix_perms/safe_pip_install.sh) so
|
|
||||||
packages end up visible to root-run ledmatrix.service, not just to
|
|
||||||
whichever non-root user happens to run the calling process (e.g. the web
|
|
||||||
interface). Falls back to installing with the calling process's own
|
|
||||||
interpreter if the wrapper isn't set up yet (the admin hasn't run
|
|
||||||
scripts/install/configure_web_sudo.sh), so dependency installation still
|
|
||||||
does *something* useful rather than hard-failing.
|
|
||||||
|
|
||||||
Always installs with the interpreter that will actually run the code
|
|
||||||
(``sys.executable`` in the fallback path, the wrapper's ``python3`` in the
|
|
||||||
sudo path) rather than a bare ``pip``/``pip3`` off PATH, which can
|
|
||||||
silently resolve to a different Python installation (e.g. system Python
|
|
||||||
vs. a virtualenv) than the one importing the package at runtime.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
req_file: Path to a requirements.txt file
|
|
||||||
timeout: Subprocess timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
subprocess.CompletedProcess from the pip (or wrapper) invocation.
|
|
||||||
Never raises on a non-zero exit; callers should check ``returncode``.
|
|
||||||
``stdout`` is prefixed with an explanatory note when the root wrapper
|
|
||||||
was unavailable and the fallback path was used.
|
|
||||||
"""
|
|
||||||
project_root = Path(__file__).resolve().parent.parent.parent
|
|
||||||
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
|
|
||||||
|
|
||||||
if wrapper.exists():
|
|
||||||
# See sudo_remove_directory / configure_web_sudo.sh for why bash must
|
|
||||||
# be invoked with an explicit, known path rather than relying on the
|
|
||||||
# wrapper's shebang: sudoers matches the exact command line.
|
|
||||||
bash_candidates = []
|
|
||||||
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
|
|
||||||
if candidate and candidate not in bash_candidates:
|
|
||||||
bash_candidates.append(candidate)
|
|
||||||
|
|
||||||
result = None
|
|
||||||
for bash_path in bash_candidates:
|
|
||||||
# bash_path and wrapper are fixed, known-good paths, and
|
|
||||||
# safe_pip_install.sh independently re-validates req_file is an
|
|
||||||
# allowed requirements.txt before installing anything as root.
|
|
||||||
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
|
||||||
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
|
|
||||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
|
||||||
)
|
|
||||||
# Redact immediately: pip can echo a private index URL's embedded
|
|
||||||
# basic-auth credentials back in its own error/progress output
|
|
||||||
# (e.g. from a requirements.txt --index-url line). Doesn't affect
|
|
||||||
# the fixed-phrase "denied" check below -- those phrases never
|
|
||||||
# overlap with URL syntax.
|
|
||||||
result.stderr = _redact_url_credentials(result.stderr)
|
|
||||||
result.stdout = _redact_url_credentials(result.stdout)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return result
|
|
||||||
# Distinguish "sudo rejected this exact command line" (worth
|
|
||||||
# trying the next bash candidate) from "sudo ran it but pip
|
|
||||||
# itself failed" (a real error — stop and surface it).
|
|
||||||
denied = any(
|
|
||||||
phrase in result.stderr
|
|
||||||
for phrase in ("a password is required", "is not allowed to run", "no tty present")
|
|
||||||
)
|
|
||||||
if not denied:
|
|
||||||
# Deliberately don't interpolate req_file or the pip output here:
|
|
||||||
# this log line is scanner-visible, and a static analyzer can't
|
|
||||||
# tell "already redacted above" from "still raw" just by looking
|
|
||||||
# at this call in isolation. The full (redacted) text is still
|
|
||||||
# available to callers via the returned CompletedProcess.
|
|
||||||
logger.warning(
|
|
||||||
"Root pip install failed (rc=%s); see the returned "
|
|
||||||
"CompletedProcess.stderr for details.",
|
|
||||||
result.returncode,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Same reasoning as above: no req_file / pip-output interpolation in
|
|
||||||
# this log line, only in the returned note/CompletedProcess.
|
|
||||||
logger.warning(
|
|
||||||
"Root pip install wrapper denied via sudo for all candidates; "
|
|
||||||
"falling back to user-level install. See the returned "
|
|
||||||
"CompletedProcess.stderr for details."
|
|
||||||
)
|
|
||||||
note = (
|
|
||||||
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
|
|
||||||
"installed for the current process's user only. Packages may not be "
|
|
||||||
"visible to ledmatrix.service if it runs as a different user — "
|
|
||||||
"run scripts/install/configure_web_sudo.sh to fix this.]\n"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"safe_pip_install.sh not found; falling back to user-level install."
|
|
||||||
)
|
|
||||||
note = (
|
|
||||||
"[safe_pip_install.sh not found; installed for the current process's "
|
|
||||||
"user only. Run scripts/install/configure_web_sudo.sh to enable "
|
|
||||||
"root installs visible to ledmatrix.service.]\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# sys.executable is this process's own interpreter (not
|
|
||||||
# attacker-influenced), and req_file is a Path built internally by callers
|
|
||||||
# (store_manager.py plugin paths, PROJECT_ROOT/requirements.txt), never
|
|
||||||
# raw external/user input. --ignore-installed matches safe_pip_install.sh:
|
|
||||||
# apt-managed packages (e.g. python3-requests) ship no pip RECORD file, so
|
|
||||||
# upgrading them would otherwise abort with "uninstall-no-record-file".
|
|
||||||
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
|
||||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
|
|
||||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
|
||||||
)
|
|
||||||
result.stderr = _redact_url_credentials(result.stderr)
|
|
||||||
result.stdout = note + _redact_url_credentials(result.stdout)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import time
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Optional, Callable
|
from typing import Dict, Any, List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
||||||
import pytz
|
import pytz
|
||||||
@@ -163,13 +163,6 @@ class DisplayController:
|
|||||||
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
||||||
self.mode_to_plugin_id: Dict[str, str] = {}
|
self.mode_to_plugin_id: Dict[str, str] = {}
|
||||||
self.plugin_display_modes: Dict[str, List[str]] = {}
|
self.plugin_display_modes: Dict[str, List[str]] = {}
|
||||||
# Per-plugin config-change callbacks, kept so we can unsubscribe a
|
|
||||||
# plugin when it is disabled live.
|
|
||||||
self._plugin_config_callbacks: Dict[str, Callable] = {}
|
|
||||||
# Set by the config-watcher thread when the enabled-plugin set changes;
|
|
||||||
# the main run loop reconciles (loads/unloads) on its own thread so
|
|
||||||
# mutating available_modes never races with rendering.
|
|
||||||
self._pending_plugin_reconcile = False
|
|
||||||
self.on_demand_active = False
|
self.on_demand_active = False
|
||||||
self.on_demand_mode: Optional[str] = None
|
self.on_demand_mode: Optional[str] = None
|
||||||
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
||||||
@@ -230,24 +223,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
|
||||||
@@ -355,10 +331,47 @@ class DisplayController:
|
|||||||
logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)",
|
logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)",
|
||||||
plugin_id, result['load_time'], loaded_count, enabled_count)
|
plugin_id, result['load_time'], loaded_count, enabled_count)
|
||||||
|
|
||||||
# Register the loaded plugin's modes, config subscription
|
# Get plugin instance and manifest
|
||||||
# and dispatch maps (shared with live enable hot-reload).
|
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
|
||||||
self._register_loaded_plugin(plugin_id)
|
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
|
||||||
|
|
||||||
|
# Prefer plugin's modes attribute if available (dynamic based on enabled leagues)
|
||||||
|
# Fall back to manifest display_modes if plugin doesn't provide modes
|
||||||
|
if plugin_instance and hasattr(plugin_instance, 'modes') and plugin_instance.modes:
|
||||||
|
display_modes = list(plugin_instance.modes)
|
||||||
|
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
|
||||||
|
else:
|
||||||
|
display_modes = manifest.get('display_modes', [plugin_id])
|
||||||
|
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
|
||||||
|
|
||||||
|
if isinstance(display_modes, list) and display_modes:
|
||||||
|
self.plugin_display_modes[plugin_id] = list(display_modes)
|
||||||
|
else:
|
||||||
|
display_modes = [plugin_id]
|
||||||
|
self.plugin_display_modes[plugin_id] = list(display_modes)
|
||||||
|
|
||||||
|
# Subscribe plugin to config changes for hot-reload
|
||||||
|
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
|
||||||
|
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||||
|
"""Callback for plugin config changes."""
|
||||||
|
try:
|
||||||
|
plugin_instance.on_config_change(new_config)
|
||||||
|
logger.debug("Plugin %s notified of config change", plugin_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error in plugin %s config change handler: %s", plugin_id, e, exc_info=True)
|
||||||
|
|
||||||
|
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
|
||||||
|
logger.debug("Subscribed plugin %s to config changes", plugin_id)
|
||||||
|
|
||||||
|
# Add plugin modes to available modes
|
||||||
|
for mode in display_modes:
|
||||||
|
self.available_modes.append(mode)
|
||||||
|
self.plugin_modes[mode] = plugin_instance
|
||||||
|
self.mode_to_plugin_id[mode] = plugin_id
|
||||||
|
logger.debug(" Added mode: %s", mode)
|
||||||
|
# Invalidate signature cache so the new instance is re-inspected
|
||||||
|
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||||
|
|
||||||
# Show progress
|
# Show progress
|
||||||
progress_pct = int((loaded_count / enabled_count) * 100)
|
progress_pct = int((loaded_count / enabled_count) * 100)
|
||||||
elapsed = time.time() - plugin_time
|
elapsed = time.time() - plugin_time
|
||||||
@@ -434,10 +447,6 @@ class DisplayController:
|
|||||||
# when the user saves settings via the web UI.
|
# when the user saves settings via the web UI.
|
||||||
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||||
self._refresh_config_cache(new_config)
|
self._refresh_config_cache(new_config)
|
||||||
# If a plugin was enabled/disabled, flag a reconcile for the main
|
|
||||||
# loop to apply (loading/unloading off the watcher thread is unsafe).
|
|
||||||
if self._enabled_set_changed(old_config, new_config):
|
|
||||||
self._pending_plugin_reconcile = True
|
|
||||||
|
|
||||||
self.config_service.subscribe(_controller_config_change)
|
self.config_service.subscribe(_controller_config_change)
|
||||||
|
|
||||||
@@ -1562,11 +1571,10 @@ class DisplayController:
|
|||||||
def run(self):
|
def run(self):
|
||||||
"""Run the display controller, switching between displays."""
|
"""Run the display controller, switching between displays."""
|
||||||
if not self.available_modes:
|
if not self.available_modes:
|
||||||
logger.warning(
|
logger.warning("No display modes are enabled. Exiting.")
|
||||||
"No display modes are enabled at startup; idling until a "
|
self.display_manager.cleanup()
|
||||||
"plugin is enabled via the web UI."
|
return
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Initialize with cached data for fast startup - let background updates refresh naturally
|
# Initialize with cached data for fast startup - let background updates refresh naturally
|
||||||
logger.info("Starting display with cached data (fast startup mode)")
|
logger.info("Starting display with cached data (fast startup mode)")
|
||||||
@@ -1574,25 +1582,6 @@ class DisplayController:
|
|||||||
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Apply plugin enable/disable edits saved via the web UI. The
|
|
||||||
# config-watcher thread only sets the flag; loading/unloading and
|
|
||||||
# rebuilding available_modes happens here on the render thread so
|
|
||||||
# it can't race with rendering. Deferred while on-demand is active
|
|
||||||
# (the flag stays set) so we don't fight its temporary-enable.
|
|
||||||
if self._pending_plugin_reconcile and not self.on_demand_active:
|
|
||||||
# Only clear the flag on success -- a retryable failure
|
|
||||||
# (e.g. discovery) leaves it set so the request isn't lost.
|
|
||||||
if self._reconcile_enabled_plugins():
|
|
||||||
self._pending_plugin_reconcile = False
|
|
||||||
|
|
||||||
if not self.available_modes:
|
|
||||||
# Nothing to render yet. Re-check _pending_plugin_reconcile
|
|
||||||
# every ~1s (rather than a long sleep) so enabling a plugin
|
|
||||||
# via the web UI is picked up about as promptly as it would
|
|
||||||
# be once modes exist and the loop is iterating per-frame.
|
|
||||||
self._sleep_with_plugin_updates(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Handle on-demand commands before rendering
|
# Handle on-demand commands before rendering
|
||||||
self._poll_on_demand_requests()
|
self._poll_on_demand_requests()
|
||||||
self._check_on_demand_expiration()
|
self._check_on_demand_expiration()
|
||||||
@@ -2330,7 +2319,7 @@ class DisplayController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error checking live priority for %s: %s", active_mode, e)
|
logger.warning("Error checking live priority for %s: %s", active_mode, e)
|
||||||
|
|
||||||
if should_rotate and self.available_modes:
|
if should_rotate:
|
||||||
self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
|
self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
|
||||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||||
self.last_mode_change = time.time()
|
self.last_mode_change = time.time()
|
||||||
@@ -2518,176 +2507,6 @@ class DisplayController:
|
|||||||
self.wifi_status_active = False
|
self.wifi_status_active = False
|
||||||
self.wifi_status_expires_at = None
|
self.wifi_status_expires_at = None
|
||||||
|
|
||||||
def _register_loaded_plugin(self, plugin_id: str) -> List[str]:
|
|
||||||
"""Register an already-loaded plugin's display modes, config-change
|
|
||||||
subscription and dispatch maps with the controller.
|
|
||||||
|
|
||||||
Shared by startup loading and live enable hot-reload so both paths
|
|
||||||
build identical controller state. Returns the registered modes.
|
|
||||||
"""
|
|
||||||
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
|
|
||||||
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
|
|
||||||
|
|
||||||
# Prefer the plugin's dynamic modes attribute (e.g. based on enabled
|
|
||||||
# leagues), else fall back to manifest display_modes, else the id.
|
|
||||||
if plugin_instance is not None and getattr(plugin_instance, 'modes', None):
|
|
||||||
display_modes = list(plugin_instance.modes)
|
|
||||||
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
|
|
||||||
else:
|
|
||||||
display_modes = manifest.get('display_modes', [plugin_id])
|
|
||||||
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
|
|
||||||
if not (isinstance(display_modes, list) and display_modes):
|
|
||||||
display_modes = [plugin_id]
|
|
||||||
self.plugin_display_modes[plugin_id] = list(display_modes)
|
|
||||||
|
|
||||||
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
|
|
||||||
# and instance as defaults so each plugin's callback targets its own
|
|
||||||
# instance (avoids late-binding when registering many plugins), and
|
|
||||||
# remember the callback so we can unsubscribe on disable.
|
|
||||||
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
|
|
||||||
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any],
|
|
||||||
_pid: str = plugin_id, _plugin: Any = plugin_instance) -> None:
|
|
||||||
"""Callback for plugin config changes."""
|
|
||||||
try:
|
|
||||||
_plugin.on_config_change(new_config)
|
|
||||||
logger.debug("Plugin %s notified of config change", _pid)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error in plugin %s config change handler: %s", _pid, e, exc_info=True)
|
|
||||||
|
|
||||||
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
|
|
||||||
self._plugin_config_callbacks[plugin_id] = config_change_callback
|
|
||||||
logger.debug("Subscribed plugin %s to config changes", plugin_id)
|
|
||||||
|
|
||||||
# Add modes to the dispatch maps.
|
|
||||||
for mode in display_modes:
|
|
||||||
if mode not in self.available_modes:
|
|
||||||
self.available_modes.append(mode)
|
|
||||||
self.plugin_modes[mode] = plugin_instance
|
|
||||||
self.mode_to_plugin_id[mode] = plugin_id
|
|
||||||
logger.debug(" Added mode: %s", mode)
|
|
||||||
# Invalidate signature cache so the new instance is re-inspected.
|
|
||||||
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
|
||||||
return display_modes
|
|
||||||
|
|
||||||
def _unregister_plugin(self, plugin_id: str) -> None:
|
|
||||||
"""Remove a plugin's modes, config subscription and instance, then
|
|
||||||
unload it. Used by live disable hot-reload."""
|
|
||||||
modes = self.plugin_display_modes.pop(plugin_id, [])
|
|
||||||
for mode in modes:
|
|
||||||
if mode in self.available_modes:
|
|
||||||
self.available_modes.remove(mode)
|
|
||||||
self.plugin_modes.pop(mode, None)
|
|
||||||
self.mode_to_plugin_id.pop(mode, None)
|
|
||||||
|
|
||||||
# Unsubscribe the plugin's config-change callback. Pop only on a
|
|
||||||
# successful unsubscribe -- if it raises, keep our reference so a
|
|
||||||
# later retry (or at least cleanup) still has the real callback
|
|
||||||
# instead of a lost one.
|
|
||||||
callback = self._plugin_config_callbacks.get(plugin_id)
|
|
||||||
if callback is not None and hasattr(self, 'config_service'):
|
|
||||||
try:
|
|
||||||
self.config_service.unsubscribe(callback, plugin_id=plugin_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Error unsubscribing plugin %s from config changes: %s", plugin_id, e)
|
|
||||||
else:
|
|
||||||
self._plugin_config_callbacks.pop(plugin_id, None)
|
|
||||||
else:
|
|
||||||
self._plugin_config_callbacks.pop(plugin_id, None)
|
|
||||||
|
|
||||||
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
|
||||||
|
|
||||||
# Tear down the instance (cleanup + on_disable + module unload).
|
|
||||||
try:
|
|
||||||
self.plugin_manager.unload_plugin(plugin_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
|
|
||||||
|
|
||||||
logger.info("Disabled plugin %s live (removed modes: %s)", plugin_id, modes)
|
|
||||||
|
|
||||||
def _enabled_set_changed(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> bool:
|
|
||||||
"""True if any top-level section's ``enabled`` flag differs between two
|
|
||||||
configs. A cheap watcher-thread check that gates the full reconcile.
|
|
||||||
Non-plugin sections (e.g. schedule) may match too; the reconcile
|
|
||||||
no-ops for anything that isn't a discovered plugin."""
|
|
||||||
def enabled_map(cfg: Dict[str, Any]) -> Dict[str, bool]:
|
|
||||||
return {
|
|
||||||
key: bool(value.get('enabled', False))
|
|
||||||
for key, value in cfg.items()
|
|
||||||
if isinstance(value, dict)
|
|
||||||
}
|
|
||||||
return enabled_map(old_config) != enabled_map(new_config)
|
|
||||||
|
|
||||||
def _reconcile_enabled_plugins(self) -> bool:
|
|
||||||
"""Load/unload plugins so the running set matches the enabled set in
|
|
||||||
config. Runs on the main display thread (never the config-watcher
|
|
||||||
thread) so mutating available_modes is race-free against rendering.
|
|
||||||
|
|
||||||
Returns True if reconciliation completed (including a no-op), or
|
|
||||||
False on a retryable failure -- the caller keeps the pending-reconcile
|
|
||||||
flag set in that case so the request isn't silently dropped."""
|
|
||||||
if self.plugin_manager is None:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
config = self.config_service.get_config()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Plugin reconcile: falling back to cached config: %s", e)
|
|
||||||
config = self.config
|
|
||||||
try:
|
|
||||||
discovered = set(self.plugin_manager.discover_plugins())
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Plugin reconcile: discovery failed: %s", e, exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
for p in discovered:
|
|
||||||
if p in config and not isinstance(config.get(p), dict):
|
|
||||||
logger.warning(
|
|
||||||
"Plugin reconcile: config for %s is a %s, not a dict; treating as disabled",
|
|
||||||
p, type(config.get(p)).__name__
|
|
||||||
)
|
|
||||||
|
|
||||||
desired = {
|
|
||||||
p for p in discovered
|
|
||||||
if isinstance(config.get(p), dict) and config.get(p, {}).get('enabled', False)
|
|
||||||
}
|
|
||||||
current = set(self.plugin_display_modes.keys())
|
|
||||||
to_add = desired - current
|
|
||||||
to_remove = current - desired
|
|
||||||
if not to_add and not to_remove:
|
|
||||||
return True
|
|
||||||
|
|
||||||
previous_mode = self.current_display_mode
|
|
||||||
|
|
||||||
for plugin_id in to_remove:
|
|
||||||
self._unregister_plugin(plugin_id)
|
|
||||||
|
|
||||||
for plugin_id in to_add:
|
|
||||||
try:
|
|
||||||
if self.plugin_manager.load_plugin(plugin_id):
|
|
||||||
modes = self._register_loaded_plugin(plugin_id)
|
|
||||||
logger.info("Enabled plugin %s live (modes: %s)", plugin_id, modes)
|
|
||||||
else:
|
|
||||||
logger.warning("Plugin reconcile: failed to load %s", plugin_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
|
|
||||||
|
|
||||||
self._resync_mode_index_after_change(previous_mode)
|
|
||||||
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
|
|
||||||
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
|
|
||||||
"""Clamp rotation state after available_modes changed. Stays on the
|
|
||||||
previous mode if it survived, otherwise restarts cleanly within range."""
|
|
||||||
if not self.available_modes:
|
|
||||||
self.current_mode_index = 0
|
|
||||||
self.current_display_mode = None
|
|
||||||
return
|
|
||||||
if previous_mode in self.available_modes:
|
|
||||||
self.current_mode_index = self.available_modes.index(previous_mode)
|
|
||||||
else:
|
|
||||||
self.current_mode_index %= len(self.available_modes)
|
|
||||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
|
||||||
|
|
||||||
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
|
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
|
||||||
"""Refresh all config-derived caches when a hot-reload fires.
|
"""Refresh all config-derived caches when a hot-reload fires.
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ else:
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from typing import Dict, Any, List, Optional
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import freetype
|
import freetype
|
||||||
@@ -181,25 +180,14 @@ class DisplayManager:
|
|||||||
# the logical image is blitted to the matrix unchanged.
|
# the logical image is blitted to the matrix unchanged.
|
||||||
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||||
self._physical_image = None # full-chain buffer reused each frame when tiling
|
self._physical_image = None # full-chain buffer reused each frame when tiling
|
||||||
# Text-width measurement cache: (text, id(font)) -> (width, font_ref)
|
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
||||||
# Avoids re-measuring the same string+font on every display() call.
|
# Avoids re-measuring the same string+font on every display() call.
|
||||||
# LRU-bounded: keys embed the TEXT, so changing strings (a clock, a
|
|
||||||
# live score) would otherwise grow it forever on a 24/7 service.
|
|
||||||
# Entries hold a strong reference to the font so its id() can't be
|
|
||||||
# recycled by a different font object — an id-keyed cache without
|
|
||||||
# the reference can return the WRONG width after garbage collection.
|
|
||||||
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||||
self._text_width_cache: "OrderedDict[tuple, Tuple[int, Any]]" = OrderedDict()
|
self._text_width_cache: Dict[tuple, int] = {}
|
||||||
self._TEXT_WIDTH_CACHE_MAX = 1024
|
|
||||||
# Snapshot settings for web preview integration (service writes, web reads)
|
# Snapshot settings for web preview integration (service writes, web reads)
|
||||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||||
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
# Snapshot failures are logged as warnings, rate-limited so a
|
|
||||||
# persistent failure (e.g. an unwritable file) can't spam the log —
|
|
||||||
# but is never silent: the snapshot's mtime doubles as the web UI's
|
|
||||||
# hardware-liveness signal, so a quiet failure makes health checks lie.
|
|
||||||
self._snapshot_fail_log_ts = 0.0
|
|
||||||
|
|
||||||
# Scrolling state tracking for graceful updates
|
# Scrolling state tracking for graceful updates
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
@@ -711,15 +699,12 @@ class DisplayManager:
|
|||||||
|
|
||||||
Results are cached by (text, font identity) so plugins that measure
|
Results are cached by (text, font identity) so plugins that measure
|
||||||
the same string every frame (e.g. to centre a score) pay only one
|
the same string every frame (e.g. to centre a score) pay only one
|
||||||
measurement per unique (text, font) pair. The entry keeps the font
|
measurement per unique (text, font) pair.
|
||||||
alive so its id() can't be recycled, and the cache is LRU-bounded so
|
|
||||||
ever-changing text (clocks, tickers) can't grow it without limit.
|
|
||||||
"""
|
"""
|
||||||
cache_key = (text, id(font))
|
cache_key = (text, id(font))
|
||||||
cached = self._text_width_cache.get(cache_key)
|
cached = self._text_width_cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
self._text_width_cache.move_to_end(cache_key)
|
return cached
|
||||||
return cached[0]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
@@ -734,9 +719,7 @@ class DisplayManager:
|
|||||||
logger.error("Error getting text width: %s", e)
|
logger.error("Error getting text width: %s", e)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
self._text_width_cache[cache_key] = (width, font)
|
self._text_width_cache[cache_key] = width
|
||||||
while len(self._text_width_cache) > self._TEXT_WIDTH_CACHE_MAX:
|
|
||||||
self._text_width_cache.popitem(last=False)
|
|
||||||
return width
|
return width
|
||||||
|
|
||||||
def get_font_height(self, font):
|
def get_font_height(self, font):
|
||||||
@@ -1181,15 +1164,5 @@ class DisplayManager:
|
|||||||
pass
|
pass
|
||||||
self._last_snapshot_ts = now
|
self._last_snapshot_ts = now
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Snapshot failures must never break display — but they must not
|
# Snapshot failures should never break display; log at debug to avoid noise
|
||||||
# be silent either: the snapshot's mtime is the web UI's display
|
logger.debug(f"Snapshot write skipped: {e}")
|
||||||
# mirror AND its hardware-liveness proxy, so a quietly failing
|
|
||||||
# write freezes the mirror and makes health checks lie (seen in
|
|
||||||
# the field: a stale root-owned /tmp file froze it for a day).
|
|
||||||
# Warn at most once per 5 minutes to avoid log spam.
|
|
||||||
if (now - self._snapshot_fail_log_ts) > 300:
|
|
||||||
self._snapshot_fail_log_ts = now
|
|
||||||
logger.warning("Snapshot write failing (web preview/health "
|
|
||||||
"mirror is stale): %s", e)
|
|
||||||
else:
|
|
||||||
logger.debug(f"Snapshot write skipped: {e}")
|
|
||||||
@@ -35,7 +35,6 @@ import urllib.request
|
|||||||
import zipfile
|
import zipfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||||
@@ -59,13 +58,7 @@ class FontManager:
|
|||||||
# Font discovery and catalog
|
# Font discovery and catalog
|
||||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||||
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
||||||
# (text, id(font)) -> ((width, height, baseline), font_ref).
|
self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline)
|
||||||
# LRU-bounded — keys embed the measured TEXT, so changing strings
|
|
||||||
# (clocks, live scores) would otherwise grow it forever. Entries
|
|
||||||
# keep the font alive so its id() can't be recycled by a different
|
|
||||||
# font object (which would silently return wrong metrics).
|
|
||||||
self.metrics_cache: "OrderedDict[Any, Tuple[Tuple[int, int, int], Any]]" = OrderedDict()
|
|
||||||
self._METRICS_CACHE_MAX = 1024
|
|
||||||
|
|
||||||
# Plugin font management
|
# Plugin font management
|
||||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
||||||
@@ -514,14 +507,10 @@ class FontManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (width, height, baseline_offset)
|
Tuple of (width, height, baseline_offset)
|
||||||
"""
|
"""
|
||||||
# Key on the text itself (hash(text) could collide) + font identity;
|
cache_key = f"{hash(text)}_{id(font)}"
|
||||||
# the entry below keeps the font referenced so the id stays valid.
|
|
||||||
cache_key = (text, id(font))
|
|
||||||
|
|
||||||
cached = self.metrics_cache.get(cache_key)
|
if cache_key in self.metrics_cache:
|
||||||
if cached is not None:
|
return self.metrics_cache[cache_key]
|
||||||
self.metrics_cache.move_to_end(cache_key)
|
|
||||||
return cached[0]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
@@ -558,9 +547,7 @@ class FontManager:
|
|||||||
baseline = 10
|
baseline = 10
|
||||||
|
|
||||||
result = (width, height, baseline)
|
result = (width, height, baseline)
|
||||||
self.metrics_cache[cache_key] = (result, font)
|
self.metrics_cache[cache_key] = result
|
||||||
while len(self.metrics_cache) > self._METRICS_CACHE_MAX:
|
|
||||||
self.metrics_cache.popitem(last=False)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -24,10 +25,7 @@ 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
|
||||||
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
|
||||||
@@ -1902,63 +1900,40 @@ 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
|
subprocess.run(
|
||||||
# web UI's "Reinstall Plugin Deps" tool uses) rather than a bare
|
['pip3', 'install', '--break-system-packages', '-r', str(requirements_file)],
|
||||||
# `pip`/`pip3` off PATH: a bare pip binary can silently resolve to
|
check=True,
|
||||||
# a different Python installation than the one that actually runs
|
capture_output=True,
|
||||||
# ledmatrix.service, so pip reports success while the package
|
text=True,
|
||||||
# stays invisible to the running plugin (e.g. missing `astral`
|
timeout=300
|
||||||
# for the weather plugin even though "install" succeeded).
|
)
|
||||||
result = install_requirements_file(requirements_file, timeout=300)
|
|
||||||
if result.returncode != 0:
|
|
||||||
self.logger.error(
|
|
||||||
f"Error installing dependencies for {plugin_path.name}: {result.stderr}"
|
|
||||||
)
|
|
||||||
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.CalledProcessError as e:
|
||||||
|
self.logger.error(f"Error installing dependencies: {e.stderr}")
|
||||||
|
return False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self.logger.error("Dependency installation timed out")
|
self.logger.error("Dependency installation timed out")
|
||||||
return False
|
return False
|
||||||
@@ -2454,6 +2429,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'],
|
||||||
@@ -2464,9 +2452,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
|
||||||
|
|||||||
@@ -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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -279,19 +279,6 @@ class PluginAdapter:
|
|||||||
# Copy the image to prevent modification
|
# Copy the image to prevent modification
|
||||||
img = cached_image.copy()
|
img = cached_image.copy()
|
||||||
|
|
||||||
# Plugins that build their own ticker image via this shared
|
|
||||||
# ScrollHelper's create_scrolling_image() get a solid-black
|
|
||||||
# leading margin exactly `display_width` columns wide baked in
|
|
||||||
# (scroll_helper.py's "initial gap before first item"). Vegas mode
|
|
||||||
# adds its own leading gap/separator around every item already,
|
|
||||||
# so leaving this in stacks a second, uncontrolled blank margin on
|
|
||||||
# top of vegas_scroll.separator_width — making this plugin's
|
|
||||||
# transitions look inconsistent with plugins that provide content
|
|
||||||
# via get_vegas_content() (which carries no such margin). Strip it
|
|
||||||
# here so every plugin contributes only its real content and the
|
|
||||||
# gap between items is governed solely by separator_width.
|
|
||||||
img = self._strip_scroll_padding(img, scroll_helper, plugin_id)
|
|
||||||
|
|
||||||
# Ensure correct height
|
# Ensure correct height
|
||||||
if img.height != self.display_height:
|
if img.height != self.display_height:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -319,69 +306,6 @@ class PluginAdapter:
|
|||||||
logger.exception("[%s] Error getting scroll_helper content", plugin_id)
|
logger.exception("[%s] Error getting scroll_helper content", plugin_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _strip_scroll_padding(
|
|
||||||
self, img: Image.Image, scroll_helper: Any, plugin_id: str
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Crop off a plugin's own leading/trailing blank margins, if present.
|
|
||||||
|
|
||||||
create_scrolling_image() always pads the *start* of its cached image
|
|
||||||
with exactly `scroll_helper.display_width` columns of solid black
|
|
||||||
(0, 0, 0) ("initial gap before first item"). Some ticker-style plugins
|
|
||||||
also pad the *end* of their own cached image (e.g. so their standalone
|
|
||||||
display exits cleanly before looping). Vegas mode already adds its own
|
|
||||||
gap/separator around every item, so either margin left in place stacks
|
|
||||||
an extra, uncontrolled blank stretch on top of `separator_width` —
|
|
||||||
only when running inside Vegas mode does this matter, since the
|
|
||||||
plugin's own standalone display still wants that margin. Detect solid
|
|
||||||
black margins up to `scroll_helper.display_width` wide on each edge and
|
|
||||||
crop them here. Images built via set_scrolling_image() (no such
|
|
||||||
margins) are left untouched.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Captured scroll_helper.cached_image (already copied)
|
|
||||||
scroll_helper: The plugin's ScrollHelper instance
|
|
||||||
plugin_id: Plugin identifier for logging
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
img, cropped on whichever edge(s) had a matching blank margin
|
|
||||||
"""
|
|
||||||
pad_width = getattr(scroll_helper, 'display_width', None)
|
|
||||||
if not isinstance(pad_width, int) or pad_width <= 0 or pad_width >= img.width:
|
|
||||||
return img
|
|
||||||
|
|
||||||
def is_solid_black(strip: Image.Image) -> bool:
|
|
||||||
return strip.convert('RGB').getextrema() == ((0, 0), (0, 0), (0, 0))
|
|
||||||
|
|
||||||
left = pad_width if is_solid_black(img.crop((0, 0, pad_width, img.height))) else 0
|
|
||||||
right = (
|
|
||||||
pad_width
|
|
||||||
if is_solid_black(img.crop((img.width - pad_width, 0, img.width, img.height)))
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
if not left and not right:
|
|
||||||
return img
|
|
||||||
|
|
||||||
# Degenerate case (e.g. an all-black cached image): don't crop past
|
|
||||||
# zero width, just leave the image as-is.
|
|
||||||
if left + right >= img.width:
|
|
||||||
return img
|
|
||||||
|
|
||||||
cropped = img.crop((left, 0, img.width - right, img.height))
|
|
||||||
|
|
||||||
# Both edges matching at once is a much stronger signal of genuine
|
|
||||||
# baked-in padding than a single edge (which has a small chance of
|
|
||||||
# coinciding with real all-black content, e.g. a dark logo touching
|
|
||||||
# one boundary). Log that case at warning level so an unexpected
|
|
||||||
# double-edge crop is easy to spot in the field.
|
|
||||||
log = logger.warning if (left and right) else logger.info
|
|
||||||
log(
|
|
||||||
"[%s] Stripping scroll_helper padding (left=%dpx, right=%dpx): %dpx -> %dpx",
|
|
||||||
plugin_id, left, right, img.width, cropped.width
|
|
||||||
)
|
|
||||||
return cropped
|
|
||||||
|
|
||||||
def _trigger_scroll_content_generation(
|
def _trigger_scroll_content_generation(
|
||||||
self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any
|
self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any
|
||||||
) -> Optional[Image.Image]:
|
) -> Optional[Image.Image]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -38,11 +38,7 @@ def mock_cache_manager():
|
|||||||
mock._memory_cache_timestamps = {}
|
mock._memory_cache_timestamps = {}
|
||||||
mock.cache_dir = "/tmp/test_cache"
|
mock.cache_dir = "/tmp/test_cache"
|
||||||
|
|
||||||
def mock_get(key: str, max_age: Optional[int] = 300,
|
def mock_get(key: str, max_age: int = 300) -> Optional[Dict]:
|
||||||
memory_ttl: Optional[int] = None) -> Optional[Dict]:
|
|
||||||
# Signature mirrors CacheManager.get — keep in sync or callers
|
|
||||||
# passing keyword args (health tracker, resource monitor) break
|
|
||||||
# only in tests, hiding real-API compatibility.
|
|
||||||
return mock._memory_cache.get(key)
|
return mock._memory_cache.get(key)
|
||||||
|
|
||||||
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
|
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
"""Tests for live plugin enable/disable hot-reload in DisplayController.
|
|
||||||
|
|
||||||
Enabling or disabling a plugin in config used to require a full display
|
|
||||||
restart because the plugin list and available_modes were built once at init.
|
|
||||||
These tests cover the reconcile path that loads/unloads plugins and rebuilds
|
|
||||||
the dispatch maps on the main thread when the enabled set changes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
def _make_plugin(modes):
|
|
||||||
plugin = MagicMock()
|
|
||||||
plugin.modes = list(modes)
|
|
||||||
return plugin
|
|
||||||
|
|
||||||
|
|
||||||
def _wire_plugin_manager(controller, plugins, discovered=None):
|
|
||||||
"""Point the controller's mock plugin_manager at a set of fake plugins.
|
|
||||||
|
|
||||||
`plugins` maps plugin_id -> mock instance (with a .modes list).
|
|
||||||
"""
|
|
||||||
pm = controller.plugin_manager
|
|
||||||
pm.discover_plugins.return_value = list(discovered if discovered is not None else plugins.keys())
|
|
||||||
pm.load_plugin.return_value = True
|
|
||||||
pm.unload_plugin.return_value = True
|
|
||||||
pm.plugin_manifests = {}
|
|
||||||
pm.get_plugin.side_effect = lambda pid: plugins.get(pid)
|
|
||||||
return pm
|
|
||||||
|
|
||||||
|
|
||||||
def _set_config(controller, cfg):
|
|
||||||
controller.config_service.get_config = lambda: cfg
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginEnableDisableHotReload:
|
|
||||||
def test_enable_plugin_live(self, test_display_controller):
|
|
||||||
controller = test_display_controller
|
|
||||||
assert controller.available_modes == []
|
|
||||||
|
|
||||||
plugin = _make_plugin(["foo"])
|
|
||||||
_wire_plugin_manager(controller, {"foo": plugin})
|
|
||||||
_set_config(controller, {"foo": {"enabled": True}})
|
|
||||||
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
|
|
||||||
assert "foo" in controller.plugin_display_modes
|
|
||||||
assert "foo" in controller.available_modes
|
|
||||||
assert controller.plugin_modes["foo"] is plugin
|
|
||||||
assert controller.mode_to_plugin_id["foo"] == "foo"
|
|
||||||
controller.plugin_manager.load_plugin.assert_any_call("foo")
|
|
||||||
|
|
||||||
def test_disable_plugin_live(self, test_display_controller):
|
|
||||||
controller = test_display_controller
|
|
||||||
plugin = _make_plugin(["live", "recent"])
|
|
||||||
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
|
|
||||||
|
|
||||||
# Enable, then disable.
|
|
||||||
_set_config(controller, {"sports": {"enabled": True}})
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
assert "sports" in controller.plugin_display_modes
|
|
||||||
assert "live" in controller.available_modes and "recent" in controller.available_modes
|
|
||||||
assert "sports" in controller._plugin_config_callbacks
|
|
||||||
|
|
||||||
_set_config(controller, {"sports": {"enabled": False}})
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
|
|
||||||
assert "sports" not in controller.plugin_display_modes
|
|
||||||
assert "live" not in controller.available_modes
|
|
||||||
assert "recent" not in controller.available_modes
|
|
||||||
assert "live" not in controller.plugin_modes
|
|
||||||
assert "recent" not in controller.mode_to_plugin_id
|
|
||||||
controller.plugin_manager.unload_plugin.assert_any_call("sports")
|
|
||||||
assert "sports" not in controller._plugin_config_callbacks
|
|
||||||
|
|
||||||
def test_disable_clamps_current_mode_index(self, test_display_controller):
|
|
||||||
controller = test_display_controller
|
|
||||||
p1 = _make_plugin(["a"])
|
|
||||||
p2 = _make_plugin(["b"])
|
|
||||||
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
|
|
||||||
|
|
||||||
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
# Add order across multiple plugins is set-driven (as at init), so
|
|
||||||
# compare membership, not order.
|
|
||||||
assert set(controller.available_modes) == {"a", "b"}
|
|
||||||
|
|
||||||
# Pretend we're currently showing p2's mode.
|
|
||||||
controller.current_mode_index = controller.available_modes.index("b")
|
|
||||||
controller.current_display_mode = "b"
|
|
||||||
|
|
||||||
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": False}})
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
|
|
||||||
assert controller.available_modes == ["a"]
|
|
||||||
# Index must be back in range and the display mode no longer the removed one.
|
|
||||||
assert 0 <= controller.current_mode_index < len(controller.available_modes)
|
|
||||||
assert controller.current_display_mode == "a"
|
|
||||||
|
|
||||||
def test_enable_keeps_current_mode(self, test_display_controller):
|
|
||||||
controller = test_display_controller
|
|
||||||
p1 = _make_plugin(["a"])
|
|
||||||
p2 = _make_plugin(["b"])
|
|
||||||
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
|
|
||||||
|
|
||||||
_set_config(controller, {"p1": {"enabled": True}})
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
controller.current_mode_index = 0
|
|
||||||
controller.current_display_mode = "a"
|
|
||||||
|
|
||||||
# Enabling p2 should not disturb the currently-showing mode.
|
|
||||||
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
|
|
||||||
assert "b" in controller.available_modes
|
|
||||||
assert controller.current_display_mode == "a"
|
|
||||||
assert controller.available_modes[controller.current_mode_index] == "a"
|
|
||||||
|
|
||||||
def test_noop_when_enabled_set_unchanged(self, test_display_controller):
|
|
||||||
controller = test_display_controller
|
|
||||||
plugin = _make_plugin(["foo"])
|
|
||||||
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
|
|
||||||
_set_config(controller, {"foo": {"enabled": True}})
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
|
|
||||||
load_calls = controller.plugin_manager.load_plugin.call_count
|
|
||||||
unload_calls = controller.plugin_manager.unload_plugin.call_count
|
|
||||||
|
|
||||||
# Reconcile again with no change — must not load/unload anything.
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
assert controller.plugin_manager.load_plugin.call_count == load_calls
|
|
||||||
assert controller.plugin_manager.unload_plugin.call_count == unload_calls
|
|
||||||
|
|
||||||
def test_reconcile_ignores_non_dict_config_value(self, test_display_controller, caplog):
|
|
||||||
"""A malformed config value (e.g. a stray string where a plugin's
|
|
||||||
section should be a dict) must be treated as disabled, not crash
|
|
||||||
the reconcile with AttributeError, and should be logged so it's
|
|
||||||
visible to whoever has to debug the malformed config."""
|
|
||||||
controller = test_display_controller
|
|
||||||
plugin = _make_plugin(["foo"])
|
|
||||||
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
|
|
||||||
_set_config(controller, {"foo": "not-a-dict"})
|
|
||||||
|
|
||||||
with caplog.at_level("WARNING"):
|
|
||||||
controller._reconcile_enabled_plugins() # must not raise
|
|
||||||
|
|
||||||
assert "foo" not in controller.plugin_display_modes
|
|
||||||
assert "foo" not in controller.available_modes
|
|
||||||
assert any("foo" in r.message and "not a dict" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
def test_disable_keeps_callback_when_unsubscribe_fails(self, test_display_controller):
|
|
||||||
"""If config_service.unsubscribe() raises, _unregister_plugin must
|
|
||||||
keep the callback in _plugin_config_callbacks rather than losing the
|
|
||||||
only reference to it (it still tears down the plugin itself)."""
|
|
||||||
controller = test_display_controller
|
|
||||||
plugin = _make_plugin(["live"])
|
|
||||||
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
|
|
||||||
|
|
||||||
_set_config(controller, {"sports": {"enabled": True}})
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
assert "sports" in controller._plugin_config_callbacks
|
|
||||||
|
|
||||||
controller.config_service.unsubscribe = MagicMock(side_effect=RuntimeError("boom"))
|
|
||||||
|
|
||||||
_set_config(controller, {"sports": {"enabled": False}})
|
|
||||||
controller._reconcile_enabled_plugins()
|
|
||||||
|
|
||||||
assert "sports" not in controller.plugin_display_modes
|
|
||||||
assert "sports" in controller._plugin_config_callbacks
|
|
||||||
|
|
||||||
|
|
||||||
class TestReconcileReturnValue:
|
|
||||||
"""_reconcile_enabled_plugins() returns True/False so the caller (run()'s
|
|
||||||
loop) only clears _pending_plugin_reconcile on success, keeping a
|
|
||||||
retryable failure's request alive instead of silently dropping it."""
|
|
||||||
|
|
||||||
def test_returns_true_on_success(self, test_display_controller):
|
|
||||||
controller = test_display_controller
|
|
||||||
plugin = _make_plugin(["foo"])
|
|
||||||
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
|
|
||||||
_set_config(controller, {"foo": {"enabled": True}})
|
|
||||||
assert controller._reconcile_enabled_plugins() is True
|
|
||||||
|
|
||||||
def test_returns_true_for_noop(self, test_display_controller):
|
|
||||||
controller = test_display_controller
|
|
||||||
_wire_plugin_manager(controller, {}, discovered=[])
|
|
||||||
_set_config(controller, {})
|
|
||||||
assert controller._reconcile_enabled_plugins() is True
|
|
||||||
|
|
||||||
def test_returns_false_on_discovery_failure(self, test_display_controller):
|
|
||||||
controller = test_display_controller
|
|
||||||
controller.plugin_manager.discover_plugins.side_effect = RuntimeError("boom")
|
|
||||||
_set_config(controller, {})
|
|
||||||
assert controller._reconcile_enabled_plugins() is False
|
|
||||||
|
|
||||||
def test_returns_true_when_no_plugin_manager(self, test_display_controller):
|
|
||||||
controller = test_display_controller
|
|
||||||
controller.plugin_manager = None
|
|
||||||
assert controller._reconcile_enabled_plugins() is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunWithNoModesEnabled:
|
|
||||||
"""Before hot-reload, an empty available_modes at startup was permanent
|
|
||||||
-- the display never came back without a restart. Now that a plugin can
|
|
||||||
be enabled live from the web UI, run() must idle rather than exit."""
|
|
||||||
|
|
||||||
def test_idles_instead_of_exiting(self, test_display_controller):
|
|
||||||
controller = test_display_controller
|
|
||||||
assert controller.available_modes == []
|
|
||||||
|
|
||||||
sleep_calls = []
|
|
||||||
|
|
||||||
def fake_sleep(duration, tick_interval=1.0):
|
|
||||||
sleep_calls.append(duration)
|
|
||||||
if len(sleep_calls) >= 3:
|
|
||||||
# Stand in for the process being torn down; run() catches
|
|
||||||
# this via its broad except + finally, same as any other
|
|
||||||
# unexpected error during the loop.
|
|
||||||
raise RuntimeError("stop-test-loop")
|
|
||||||
|
|
||||||
controller._sleep_with_plugin_updates = fake_sleep
|
|
||||||
|
|
||||||
controller.run()
|
|
||||||
|
|
||||||
# Old behavior returned before ever reaching the loop body, so
|
|
||||||
# _sleep_with_plugin_updates would never have been called. The idle
|
|
||||||
# tick is short (not a long sleep) so a plugin enabled via the web
|
|
||||||
# UI while idle is picked up about as promptly as it would be once
|
|
||||||
# modes exist and the loop is iterating per-frame.
|
|
||||||
assert sleep_calls == [1, 1, 1]
|
|
||||||
|
|
||||||
|
|
||||||
class TestEnabledSetChanged:
|
|
||||||
def test_detects_toggle(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
assert c._enabled_set_changed({"a": {"enabled": True}}, {"a": {"enabled": False}}) is True
|
|
||||||
|
|
||||||
def test_no_change(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
cfg = {"a": {"enabled": True}, "b": {"enabled": False}}
|
|
||||||
assert c._enabled_set_changed(cfg, dict(cfg)) is False
|
|
||||||
|
|
||||||
def test_new_enabled_section(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
assert c._enabled_set_changed(
|
|
||||||
{"a": {"enabled": True}},
|
|
||||||
{"a": {"enabled": True}, "b": {"enabled": True}},
|
|
||||||
) is True
|
|
||||||
|
|
||||||
def test_ignores_non_enabled_value_edits(self, test_display_controller):
|
|
||||||
c = test_display_controller
|
|
||||||
assert c._enabled_set_changed(
|
|
||||||
{"a": {"enabled": True, "duration": 30}},
|
|
||||||
{"a": {"enabled": True, "duration": 45}},
|
|
||||||
) is False
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/vegas_mode/plugin_adapter.py
|
|
||||||
|
|
||||||
Covers PluginAdapter._strip_scroll_padding(): the heuristic that crops a
|
|
||||||
plugin's own baked-in leading/trailing blank margins before Vegas mode
|
|
||||||
composites the content, so vegas_scroll.separator_width is the only gap
|
|
||||||
applied between items.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
|
||||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
|
||||||
|
|
||||||
|
|
||||||
class FakeDisplayManager:
|
|
||||||
width = 64
|
|
||||||
height = 32
|
|
||||||
|
|
||||||
|
|
||||||
class FakePlugin:
|
|
||||||
def __init__(self, scroll_helper):
|
|
||||||
self.scroll_helper = scroll_helper
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def adapter():
|
|
||||||
return PluginAdapter(FakeDisplayManager())
|
|
||||||
|
|
||||||
|
|
||||||
def _solid(width: int, height: int, color: tuple) -> Image.Image:
|
|
||||||
"""Create a solid-color RGB image of the given dimensions."""
|
|
||||||
return Image.new('RGB', (width, height), color)
|
|
||||||
|
|
||||||
|
|
||||||
class TestStripScrollPadding:
|
|
||||||
def test_leading_pad_from_create_scrolling_image_is_stripped(self, adapter):
|
|
||||||
sh = ScrollHelper(64, 32)
|
|
||||||
item = _solid(40, 32, (200, 50, 50))
|
|
||||||
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
|
|
||||||
|
|
||||||
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
|
||||||
assert images[0].width == 40
|
|
||||||
assert images[0].getpixel((0, 0)) == (200, 50, 50)
|
|
||||||
|
|
||||||
def test_leading_and_trailing_pad_both_stripped(self, adapter):
|
|
||||||
sh = ScrollHelper(64, 32)
|
|
||||||
content_w = 80
|
|
||||||
full = _solid(64 + content_w + 64, 32, (0, 0, 0))
|
|
||||||
full.paste(_solid(content_w, 32, (10, 220, 30)), (64, 0))
|
|
||||||
sh.set_scrolling_image(full)
|
|
||||||
|
|
||||||
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
|
||||||
assert images[0].width == content_w
|
|
||||||
assert images[0].getpixel((0, 0)) == (10, 220, 30)
|
|
||||||
assert images[0].getpixel((content_w - 1, 0)) == (10, 220, 30)
|
|
||||||
|
|
||||||
def test_leading_only_pad_stripped_trailing_content_kept(self, adapter):
|
|
||||||
sh = ScrollHelper(64, 32)
|
|
||||||
content_w = 80
|
|
||||||
full = _solid(64 + content_w, 32, (0, 0, 0))
|
|
||||||
full.paste(_solid(content_w, 32, (5, 5, 250)), (64, 0))
|
|
||||||
sh.set_scrolling_image(full)
|
|
||||||
|
|
||||||
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
|
||||||
assert images[0].width == content_w
|
|
||||||
assert images[0].getpixel((0, 0)) == (5, 5, 250)
|
|
||||||
|
|
||||||
def test_trailing_only_pad_stripped_leading_content_kept(self, adapter):
|
|
||||||
sh = ScrollHelper(64, 32)
|
|
||||||
content_w = 80
|
|
||||||
full = _solid(content_w + 64, 32, (0, 0, 0))
|
|
||||||
full.paste(_solid(content_w, 32, (5, 5, 250)), (0, 0))
|
|
||||||
sh.set_scrolling_image(full)
|
|
||||||
|
|
||||||
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
|
||||||
assert images[0].width == content_w
|
|
||||||
assert images[0].getpixel((0, 0)) == (5, 5, 250)
|
|
||||||
|
|
||||||
def test_no_margin_image_left_untouched(self, adapter):
|
|
||||||
sh = ScrollHelper(64, 32)
|
|
||||||
raw = _solid(150, 32, (5, 5, 5))
|
|
||||||
raw.paste(_solid(50, 32, (123, 45, 67)), (0, 0))
|
|
||||||
sh.set_scrolling_image(raw)
|
|
||||||
|
|
||||||
images = adapter._get_scroll_helper_content(FakePlugin(sh), "no_margin")
|
|
||||||
assert images[0].width == 150
|
|
||||||
|
|
||||||
def test_degenerate_all_black_image_left_untouched(self, adapter):
|
|
||||||
sh = ScrollHelper(64, 32)
|
|
||||||
sh.set_scrolling_image(_solid(50, 32, (0, 0, 0)))
|
|
||||||
|
|
||||||
images = adapter._get_scroll_helper_content(FakePlugin(sh), "all_black")
|
|
||||||
assert images[0].width == 50
|
|
||||||
|
|
||||||
def test_missing_display_width_attribute_left_untouched(self, adapter):
|
|
||||||
sh = ScrollHelper(64, 32)
|
|
||||||
item = _solid(40, 32, (200, 50, 50))
|
|
||||||
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
|
|
||||||
original_width = sh.cached_image.width
|
|
||||||
del sh.display_width
|
|
||||||
|
|
||||||
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
|
||||||
assert images[0].width == original_width
|
|
||||||
|
|
||||||
def test_pad_width_not_smaller_than_image_left_untouched(self, adapter):
|
|
||||||
sh = ScrollHelper(64, 32)
|
|
||||||
sh.set_scrolling_image(_solid(64, 32, (0, 0, 0)))
|
|
||||||
|
|
||||||
images = adapter._get_scroll_helper_content(FakePlugin(sh), "narrow")
|
|
||||||
assert images[0].width == 64
|
|
||||||
|
|
||||||
def test_both_edges_matching_logs_warning(self, adapter, caplog):
|
|
||||||
sh = ScrollHelper(64, 32)
|
|
||||||
content_w = 80
|
|
||||||
full = _solid(64 + content_w + 64, 32, (0, 0, 0))
|
|
||||||
full.paste(_solid(content_w, 32, (10, 220, 30)), (64, 0))
|
|
||||||
sh.set_scrolling_image(full)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="src.vegas_mode.plugin_adapter"):
|
|
||||||
adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
|
||||||
|
|
||||||
assert any("Stripping scroll_helper padding" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
def test_single_edge_match_logs_info_not_warning(self, adapter, caplog):
|
|
||||||
sh = ScrollHelper(64, 32)
|
|
||||||
item = _solid(40, 32, (200, 50, 50))
|
|
||||||
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="src.vegas_mode.plugin_adapter"):
|
|
||||||
adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
|
|
||||||
|
|
||||||
strip_records = [r for r in caplog.records if "Stripping scroll_helper padding" in r.message]
|
|
||||||
assert len(strip_records) == 1
|
|
||||||
assert strip_records[0].levelno == logging.INFO
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
"""
|
|
||||||
Smoke tests for the settings tooltips + search UI.
|
|
||||||
|
|
||||||
These render the settings partials through Flask and assert that every settings
|
|
||||||
field carries:
|
|
||||||
- a stable search anchor id (`id="setting-..."` on its .form-group), and
|
|
||||||
- an info tooltip (`class="help-tip"` emitted by the help_tip macro).
|
|
||||||
|
|
||||||
They guard against macro/import breakage and against fields losing their anchor
|
|
||||||
or tooltip when partials are edited. See web_interface/static/v3/js/tooltips.js
|
|
||||||
and settings-search.js for the consumers of this markup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from flask import Flask
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
|
|
||||||
# A realistic-enough config so the hand-written partials render every field.
|
|
||||||
REALISTIC_CONFIG = {
|
|
||||||
"web_display_autostart": True,
|
|
||||||
"timezone": "America/Chicago",
|
|
||||||
"location": {"city": "Dallas", "state": "Texas", "country": "US"},
|
|
||||||
"plugin_system": {
|
|
||||||
"auto_discover": True,
|
|
||||||
"auto_load_enabled": True,
|
|
||||||
"development_mode": False,
|
|
||||||
"plugins_directory": "plugin-repos",
|
|
||||||
},
|
|
||||||
"schedule": {},
|
|
||||||
"dim_schedule": {"dim_brightness": 30},
|
|
||||||
"sync": {"role": "standalone", "port": 5765, "follower_position": "left"},
|
|
||||||
"display": {
|
|
||||||
"hardware": {
|
|
||||||
"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1,
|
|
||||||
"brightness": 95, "hardware_mapping": "adafruit-hat-pwm",
|
|
||||||
"led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "",
|
|
||||||
"row_address_type": 0, "scan_mode": 0, "pwm_bits": 9,
|
|
||||||
"pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130,
|
|
||||||
"limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False,
|
|
||||||
"inverse_colors": False, "show_refresh_rate": False,
|
|
||||||
},
|
|
||||||
"runtime": {"gpio_slowdown": 3, "rp1_rio": 0},
|
|
||||||
"double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"},
|
|
||||||
"use_short_date_format": False,
|
|
||||||
"dynamic_duration": {"max_duration_seconds": 180},
|
|
||||||
"vegas_scroll": {
|
|
||||||
"enabled": False, "scroll_speed": 50, "separator_width": 32,
|
|
||||||
"target_fps": 125, "buffer_ahead": 2,
|
|
||||||
"plugin_order": [], "excluded_plugins": [],
|
|
||||||
},
|
|
||||||
"display_durations": {"clock": 15, "weather": 30},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
base = PROJECT_ROOT / "web_interface"
|
|
||||||
app = Flask(
|
|
||||||
__name__,
|
|
||||||
template_folder=str(base / "templates"),
|
|
||||||
static_folder=str(base / "static"),
|
|
||||||
)
|
|
||||||
app.config["TESTING"] = True
|
|
||||||
|
|
||||||
from web_interface.blueprints import pages_v3 as pv
|
|
||||||
|
|
||||||
mock_cm = MagicMock()
|
|
||||||
mock_cm.load_config.return_value = REALISTIC_CONFIG
|
|
||||||
mock_cm.get_raw_file_content.return_value = REALISTIC_CONFIG
|
|
||||||
mock_cm.get_config_path.return_value = "config/config.json"
|
|
||||||
mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
|
|
||||||
pv.pages_v3.config_manager = mock_cm
|
|
||||||
pv.pages_v3.plugin_manager = MagicMock(plugins={})
|
|
||||||
|
|
||||||
app.register_blueprint(pv.pages_v3, url_prefix="/v3")
|
|
||||||
return app.test_client()
|
|
||||||
|
|
||||||
|
|
||||||
# Settings tabs that must expose searchable, tooltipped fields.
|
|
||||||
SETTINGS_TABS = ["general", "display", "durations", "schedule", "wifi"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("tab", SETTINGS_TABS)
|
|
||||||
def test_settings_partial_has_tooltips_and_anchors(client, tab):
|
|
||||||
resp = client.get(f"/v3/partials/{tab}")
|
|
||||||
assert resp.status_code == 200, f"{tab} partial failed to render"
|
|
||||||
body = resp.get_data(as_text=True)
|
|
||||||
|
|
||||||
assert 'class="help-tip"' in body, f"{tab}: no tooltips rendered"
|
|
||||||
assert 'id="setting-' in body, f"{tab}: no search anchors rendered"
|
|
||||||
# Every settings field should be both anchored and tooltipped; tooltip count
|
|
||||||
# should not exceed anchor count (each field has at most one help_tip).
|
|
||||||
anchors = body.count('id="setting-')
|
|
||||||
tips = body.count('class="help-tip"')
|
|
||||||
assert tips >= 1 and anchors >= 1
|
|
||||||
assert tips <= anchors, f"{tab}: more tooltips ({tips}) than anchors ({anchors})"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("tab", SETTINGS_TABS)
|
|
||||||
def test_settings_partial_has_per_tab_filter(client, tab):
|
|
||||||
body = client.get(f"/v3/partials/{tab}").get_data(as_text=True)
|
|
||||||
assert 'class="settings-filter' in body, f"{tab}: per-tab filter box missing"
|
|
||||||
|
|
||||||
|
|
||||||
def test_display_tooltip_carries_rich_text(client):
|
|
||||||
# The brightness tooltip should include the authored guidance, not just a label.
|
|
||||||
body = client.get("/v3/partials/display").get_data(as_text=True)
|
|
||||||
assert 'id="setting-display-brightness"' in body
|
|
||||||
assert "Recommended:" in body # rich detail authored into a tooltip
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_index_endpoint(client):
|
|
||||||
"""The server-built search index powers the global settings search."""
|
|
||||||
resp = client.get("/v3/settings/search-index")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
assert isinstance(data, dict) and isinstance(data.get("fields"), list)
|
|
||||||
fields = data["fields"]
|
|
||||||
assert len(fields) >= 40, "expected the core settings fields to be indexed"
|
|
||||||
|
|
||||||
by_id = {f["anchorId"]: f for f in fields}
|
|
||||||
# Representative fields across tabs must be present with usable text.
|
|
||||||
for anchor in ("setting-general-timezone", "setting-display-brightness",
|
|
||||||
"setting-wifi-password", "setting-durations-clock"):
|
|
||||||
assert anchor in by_id, f"{anchor} missing from search index"
|
|
||||||
entry = by_id[anchor]
|
|
||||||
assert entry["label"], f"{anchor} has no label"
|
|
||||||
assert entry["help"], f"{anchor} has no tooltip help"
|
|
||||||
assert entry["tab"] and entry["tabLabel"]
|
|
||||||
|
|
||||||
# Every entry must carry a non-empty label and a stable anchor id.
|
|
||||||
assert all(f["label"] and f["anchorId"].startswith("setting-") for f in fields)
|
|
||||||
# Section context is captured for grouped fields (e.g. Display hardware).
|
|
||||||
assert by_id["setting-display-brightness"]["section"] == "Hardware Configuration"
|
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_config_partial_has_filter_and_nested_anchors():
|
|
||||||
"""Plugin config tabs expose the per-tab filter and anchor nested fields.
|
|
||||||
|
|
||||||
The client fixture has no installed plugins, so render the partial directly
|
|
||||||
with a schema that includes a nested section (render_nested_section).
|
|
||||||
"""
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
||||||
|
|
||||||
env = Environment(
|
|
||||||
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
|
|
||||||
autoescape=select_autoescape(["html"]),
|
|
||||||
)
|
|
||||||
plugin = {
|
|
||||||
"id": "demo-plugin", "name": "Demo Plugin", "description": "A demo",
|
|
||||||
"enabled": True, "author": "me", "version": "1.0.0",
|
|
||||||
}
|
|
||||||
schema = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"title_text": {"type": "string", "title": "Title Text",
|
|
||||||
"description": "The heading."},
|
|
||||||
"advanced": {
|
|
||||||
"type": "object", "title": "Advanced Options",
|
|
||||||
"description": "Nested options.",
|
|
||||||
"properties": {
|
|
||||||
"scroll_speed": {"type": "integer", "title": "Scroll Speed",
|
|
||||||
"description": "Pixels per second."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
config = {"title_text": "Hi", "advanced": {"scroll_speed": 50}}
|
|
||||||
html = env.get_template("v3/partials/plugin_config.html").render(
|
|
||||||
plugin=plugin, schema=schema, config=config
|
|
||||||
)
|
|
||||||
|
|
||||||
assert 'class="settings-filter' in html, "plugin config: per-tab filter box missing"
|
|
||||||
assert "nested-content" in html, "plugin config: nested section not rendered"
|
|
||||||
assert 'id="setting-' in html, "plugin config: no search anchors rendered"
|
|
||||||
assert 'class="help-tip"' in html, "plugin config: no tooltips rendered"
|
|
||||||
@@ -3,7 +3,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -28,7 +27,6 @@ from src.plugin_system.health_monitor import PluginHealthMonitor
|
|||||||
|
|
||||||
_JOURNALCTL = shutil.which('journalctl')
|
_JOURNALCTL = shutil.which('journalctl')
|
||||||
_SYSTEMCTL = shutil.which('systemctl')
|
_SYSTEMCTL = shutil.which('systemctl')
|
||||||
_VCGENCMD = shutil.which('vcgencmd')
|
|
||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -160,22 +158,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')
|
||||||
|
|
||||||
@@ -516,37 +498,6 @@ class _StreamBroadcaster:
|
|||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _get_power_status():
|
|
||||||
"""Check Raspberry Pi under-voltage/throttling status via vcgencmd.
|
|
||||||
|
|
||||||
Returns a dict of decoded flags, or None on non-Pi platforms (no
|
|
||||||
vcgencmd) or if the call fails for any reason. See:
|
|
||||||
https://www.raspberrypi.com/documentation/computers/os.html#get_throttled
|
|
||||||
"""
|
|
||||||
if not _VCGENCMD:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_VCGENCMD, 'get_throttled'], capture_output=True, text=True, timeout=2
|
|
||||||
)
|
|
||||||
match = re.search(r'0x([0-9a-fA-F]+)', result.stdout)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
bits = int(match.group(1), 16)
|
|
||||||
return {
|
|
||||||
'under_voltage_now': bool(bits & 0x1),
|
|
||||||
'freq_capped_now': bool(bits & 0x2),
|
|
||||||
'throttled_now': bool(bits & 0x4),
|
|
||||||
'soft_temp_limit_now': bool(bits & 0x8),
|
|
||||||
'under_voltage_occurred': bool(bits & 0x10000),
|
|
||||||
'freq_capped_occurred': bool(bits & 0x20000),
|
|
||||||
'throttled_occurred': bool(bits & 0x40000),
|
|
||||||
'soft_temp_limit_occurred': bool(bits & 0x80000),
|
|
||||||
}
|
|
||||||
except (subprocess.SubprocessError, OSError, ValueError) as e:
|
|
||||||
app.logger.warning("vcgencmd get_throttled failed: %s", e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# System status generator for SSE
|
# System status generator for SSE
|
||||||
def system_status_generator():
|
def system_status_generator():
|
||||||
"""Generate system status updates"""
|
"""Generate system status updates"""
|
||||||
@@ -593,8 +544,7 @@ def system_status_generator():
|
|||||||
'cpu_percent': cpu_percent,
|
'cpu_percent': cpu_percent,
|
||||||
'memory_used_percent': memory_used_percent,
|
'memory_used_percent': memory_used_percent,
|
||||||
'cpu_temp': cpu_temp,
|
'cpu_temp': cpu_temp,
|
||||||
'disk_used_percent': 0,
|
'disk_used_percent': 0
|
||||||
'power': _get_power_status()
|
|
||||||
}
|
}
|
||||||
yield status
|
yield status
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from src.web_interface.validators import (
|
|||||||
validate_file_upload
|
validate_file_upload
|
||||||
)
|
)
|
||||||
from src.error_aggregator import get_error_aggregator
|
from src.error_aggregator import get_error_aggregator
|
||||||
from src.common.permission_utils import install_requirements_file
|
|
||||||
|
|
||||||
_SUDO = shutil.which('sudo')
|
_SUDO = shutil.which('sudo')
|
||||||
_JOURNALCTL = shutil.which('journalctl')
|
_JOURNALCTL = shutil.which('journalctl')
|
||||||
@@ -44,21 +43,6 @@ def _truncate_output(stdout: str, stderr: str) -> str:
|
|||||||
return combined
|
return combined
|
||||||
|
|
||||||
|
|
||||||
def _pip_install_requirements(req_file: Path, timeout: int) -> subprocess.CompletedProcess:
|
|
||||||
"""Install a requirements.txt file, preferring the vetted sudo wrapper so
|
|
||||||
the packages are visible to root-run ledmatrix.service — not just to
|
|
||||||
whichever non-root user runs this web process. Falls back to installing
|
|
||||||
for the current process only if the wrapper isn't set up yet (i.e. the
|
|
||||||
admin hasn't run scripts/install/configure_web_sudo.sh since upgrading),
|
|
||||||
so the button still does *something* useful rather than hard-failing.
|
|
||||||
|
|
||||||
Thin wrapper around the shared implementation in permission_utils so the
|
|
||||||
Plugin Store's own dependency installation (store_manager.py) follows the
|
|
||||||
exact same root-visible install path instead of a divergent one.
|
|
||||||
"""
|
|
||||||
return install_requirements_file(req_file, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _scrub_git_remote_url(url: str) -> str:
|
def _scrub_git_remote_url(url: str) -> str:
|
||||||
"""Strip embedded username/password from an HTTPS remote URL before returning it to the UI."""
|
"""Strip embedded username/password from an HTTPS remote URL before returning it to the UI."""
|
||||||
try:
|
try:
|
||||||
@@ -1687,7 +1671,10 @@ def execute_system_action():
|
|||||||
req_file = PROJECT_ROOT / 'requirements.txt'
|
req_file = PROJECT_ROOT / 'requirements.txt'
|
||||||
if not req_file.exists():
|
if not req_file.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
||||||
result = _pip_install_requirements(req_file, timeout=120)
|
result = subprocess.run(
|
||||||
|
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req_file)],
|
||||||
|
capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT)
|
||||||
|
)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success' if result.returncode == 0 else 'error',
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
|
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
|
||||||
@@ -1695,20 +1682,17 @@ def execute_system_action():
|
|||||||
})
|
})
|
||||||
elif action == 'install_plugin_requirements':
|
elif action == 'install_plugin_requirements':
|
||||||
active_pm = getattr(api_v3, 'plugin_manager', None)
|
active_pm = getattr(api_v3, 'plugin_manager', None)
|
||||||
if active_pm:
|
plugins_dir = Path(active_pm.plugins_dir) if active_pm else PROJECT_ROOT / 'plugin-repos'
|
||||||
plugins_dir = Path(active_pm.plugins_dir)
|
|
||||||
else:
|
|
||||||
_cm = getattr(api_v3, 'config_manager', None)
|
|
||||||
_cfg = _cm.load_config() if _cm else {}
|
|
||||||
_dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos')
|
|
||||||
plugins_dir = Path(_dir_name) if os.path.isabs(_dir_name) else PROJECT_ROOT / _dir_name
|
|
||||||
results = []
|
results = []
|
||||||
if plugins_dir.exists():
|
if plugins_dir.exists():
|
||||||
for p in sorted(plugins_dir.iterdir()):
|
for p in sorted(plugins_dir.iterdir()):
|
||||||
req = p / 'requirements.txt'
|
req = p / 'requirements.txt'
|
||||||
if p.is_dir() and req.exists():
|
if p.is_dir() and req.exists():
|
||||||
try:
|
try:
|
||||||
r = _pip_install_requirements(req, timeout=60)
|
r = subprocess.run(
|
||||||
|
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req)],
|
||||||
|
capture_output=True, text=True, timeout=60
|
||||||
|
)
|
||||||
results.append({
|
results.append({
|
||||||
'plugin': p.name,
|
'plugin': p.name,
|
||||||
'ok': r.returncode == 0,
|
'ok': r.returncode == 0,
|
||||||
@@ -2073,18 +2057,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 +2138,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 +2157,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 +2172,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 +2248,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',
|
||||||
@@ -4703,49 +4619,6 @@ def save_plugin_config():
|
|||||||
if 'application/json' in content_type:
|
if 'application/json' in content_type:
|
||||||
schema = schema_mgr.load_schema(plugin_id, use_cache=False)
|
schema = schema_mgr.load_schema(plugin_id, use_cache=False)
|
||||||
|
|
||||||
# JSON path: fix numeric-keyed dicts that should be arrays.
|
|
||||||
# JS dotToNested() converts feeds.custom_feeds.0.name → {'0': {name:...}}
|
|
||||||
# instead of [{name:...}]. The form-data path has fix_array_structures for this;
|
|
||||||
# mirror that logic here for JSON submissions.
|
|
||||||
if 'application/json' in content_type and schema and 'properties' in schema:
|
|
||||||
def _fix_json_arrays(cfg, props):
|
|
||||||
for k, ps in props.items():
|
|
||||||
if not isinstance(cfg, dict) or k not in cfg:
|
|
||||||
continue
|
|
||||||
pt = ps.get('type')
|
|
||||||
val = cfg[k]
|
|
||||||
if pt == 'array':
|
|
||||||
items_schema = ps.get('items', {})
|
|
||||||
item_type = items_schema.get('type')
|
|
||||||
if isinstance(val, dict):
|
|
||||||
keys = list(val.keys())
|
|
||||||
if keys and all(str(x).isdigit() for x in keys):
|
|
||||||
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
|
||||||
arr = [val[sk] for sk in sorted_keys]
|
|
||||||
if item_type in ('integer', 'number'):
|
|
||||||
converted = []
|
|
||||||
for v in arr:
|
|
||||||
if isinstance(v, str):
|
|
||||||
try:
|
|
||||||
converted.append(int(v) if item_type == 'integer' else float(v))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
converted.append(v)
|
|
||||||
else:
|
|
||||||
converted.append(v)
|
|
||||||
arr = converted
|
|
||||||
cfg[k] = arr
|
|
||||||
elif not keys:
|
|
||||||
cfg[k] = []
|
|
||||||
# Recurse into each element when items are objects with properties,
|
|
||||||
# covering both freshly-converted and already-list values.
|
|
||||||
if item_type == 'object' and 'properties' in items_schema:
|
|
||||||
for elem in (cfg[k] if isinstance(cfg[k], list) else []):
|
|
||||||
if isinstance(elem, dict):
|
|
||||||
_fix_json_arrays(elem, items_schema['properties'])
|
|
||||||
elif pt == 'object' and 'properties' in ps and isinstance(val, dict):
|
|
||||||
_fix_json_arrays(val, ps['properties'])
|
|
||||||
_fix_json_arrays(plugin_config, schema['properties'])
|
|
||||||
|
|
||||||
# PRE-PROCESSING: Preserve 'enabled' state if not in request
|
# PRE-PROCESSING: Preserve 'enabled' state if not in request
|
||||||
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
|
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
|
||||||
if 'enabled' not in plugin_config:
|
if 'enabled' not in plugin_config:
|
||||||
@@ -7252,74 +7125,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"""
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from flask import Blueprint, render_template, flash, jsonify
|
from flask import Blueprint, render_template, flash
|
||||||
from jinja2 import TemplateNotFound
|
from jinja2 import TemplateNotFound
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
from html.parser import HTMLParser
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -23,114 +22,6 @@ plugin_store_manager = None
|
|||||||
|
|
||||||
pages_v3 = Blueprint('pages_v3', __name__)
|
pages_v3 = Blueprint('pages_v3', __name__)
|
||||||
|
|
||||||
|
|
||||||
class _SettingsIndexParser(HTMLParser):
|
|
||||||
"""Extract searchable settings fields from a rendered partial's HTML.
|
|
||||||
|
|
||||||
Captures one entry per ``<div class="form-group" id="setting-…">``: the
|
|
||||||
anchor id, ``data-setting-key``, the field's ``<label>`` text, the
|
|
||||||
``.help-tip`` tooltip text (``data-tooltip``), and the nearest preceding
|
|
||||||
``<h3>``/``<h4>`` section heading. Parsing the *rendered* HTML (rather than
|
|
||||||
the schema) guarantees the anchor ids match the live DOM exactly, so the
|
|
||||||
search index cannot drift from what users actually see.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, tab, tab_label):
|
|
||||||
super().__init__(convert_charrefs=True)
|
|
||||||
self.tab = tab
|
|
||||||
self.tab_label = tab_label
|
|
||||||
self.fields = []
|
|
||||||
self._section = ''
|
|
||||||
self._field = None
|
|
||||||
self._depth = 0 # open-div depth within the current field
|
|
||||||
self._in_label = False
|
|
||||||
self._label_parts = []
|
|
||||||
self._in_heading = False
|
|
||||||
self._heading_parts = []
|
|
||||||
|
|
||||||
def handle_starttag(self, tag, attrs):
|
|
||||||
a = {k: (v or '') for k, v in attrs}
|
|
||||||
classes = a.get('class', '').split()
|
|
||||||
# Section headings (only when not already inside a field)
|
|
||||||
if tag in ('h3', 'h4') and self._field is None:
|
|
||||||
self._in_heading = True
|
|
||||||
self._heading_parts = []
|
|
||||||
if tag == 'div':
|
|
||||||
fid = a.get('id', '')
|
|
||||||
if self._field is None and 'form-group' in classes and fid.startswith('setting-'):
|
|
||||||
self._field = {
|
|
||||||
'anchorId': fid,
|
|
||||||
'key': a.get('data-setting-key', '') or fid[len('setting-'):],
|
|
||||||
'label': '',
|
|
||||||
'help': '',
|
|
||||||
'section': self._section,
|
|
||||||
'tab': self.tab,
|
|
||||||
'tabLabel': self.tab_label,
|
|
||||||
}
|
|
||||||
self._depth = 1
|
|
||||||
return
|
|
||||||
if self._field is not None:
|
|
||||||
self._depth += 1
|
|
||||||
if self._field is not None:
|
|
||||||
if tag == 'label' and not self._field['label']:
|
|
||||||
self._in_label = True
|
|
||||||
self._label_parts = []
|
|
||||||
if tag == 'button' and 'help-tip' in classes and not self._field['help']:
|
|
||||||
self._field['help'] = a.get('data-tooltip', '')
|
|
||||||
|
|
||||||
def handle_data(self, data):
|
|
||||||
if self._in_label:
|
|
||||||
self._label_parts.append(data)
|
|
||||||
elif self._in_heading:
|
|
||||||
self._heading_parts.append(data)
|
|
||||||
|
|
||||||
def handle_endtag(self, tag):
|
|
||||||
if tag in ('h3', 'h4') and self._in_heading:
|
|
||||||
self._in_heading = False
|
|
||||||
self._section = ' '.join(''.join(self._heading_parts).split()).strip()
|
|
||||||
return
|
|
||||||
if self._field is None:
|
|
||||||
return
|
|
||||||
if tag == 'label' and self._in_label:
|
|
||||||
self._in_label = False
|
|
||||||
self._field['label'] = ' '.join(''.join(self._label_parts).split()).strip()
|
|
||||||
elif tag == 'div':
|
|
||||||
self._depth -= 1
|
|
||||||
if self._depth <= 0:
|
|
||||||
if self._field['label']:
|
|
||||||
self.fields.append(self._field)
|
|
||||||
self._field = None
|
|
||||||
self._depth = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _partial_html(loader):
|
|
||||||
"""Run a partial loader and return its HTML string ('' on error)."""
|
|
||||||
try:
|
|
||||||
result = loader()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("search-index: partial render failed", exc_info=True)
|
|
||||||
return ''
|
|
||||||
if isinstance(result, str):
|
|
||||||
return result
|
|
||||||
if isinstance(result, tuple): # loaders return (msg, status) on error
|
|
||||||
return ''
|
|
||||||
try:
|
|
||||||
return result.get_data(as_text=True)
|
|
||||||
except Exception:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_settings_fields(html, tab, tab_label):
|
|
||||||
parser = _SettingsIndexParser(tab, tab_label)
|
|
||||||
parser.feed(html)
|
|
||||||
return parser.fields
|
|
||||||
|
|
||||||
|
|
||||||
# Cache the built index keyed on the installed-plugin set. Core labels/tooltips
|
|
||||||
# are static template text, so only a change in installed plugins invalidates it.
|
|
||||||
_SEARCH_INDEX_CACHE = {'sig': None, 'fields': None}
|
|
||||||
|
|
||||||
|
|
||||||
@pages_v3.route('/')
|
@pages_v3.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Main v3 interface page"""
|
"""Main v3 interface page"""
|
||||||
@@ -220,56 +111,6 @@ def load_plugin_config_partial(plugin_id):
|
|||||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
||||||
|
|
||||||
|
|
||||||
@pages_v3.route('/settings/search-index')
|
|
||||||
def settings_search_index():
|
|
||||||
"""Return a flat JSON index of every searchable setting (core + plugin).
|
|
||||||
|
|
||||||
Powers the web UI's global settings search. Built by rendering the settings
|
|
||||||
partials server-side and extracting field metadata, then cached per
|
|
||||||
installed-plugin set so it is off the display's hot path.
|
|
||||||
"""
|
|
||||||
# Core settings tabs: (activeTab value, human label, loader).
|
|
||||||
core_tabs = [
|
|
||||||
('general', 'General', _load_general_partial),
|
|
||||||
('display', 'Display', _load_display_partial),
|
|
||||||
('durations', 'Durations', _load_durations_partial),
|
|
||||||
('schedule', 'Schedule', _load_schedule_partial),
|
|
||||||
('wifi', 'WiFi', _load_wifi_partial),
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
plugin_ids = []
|
|
||||||
if pages_v3.plugin_manager:
|
|
||||||
try:
|
|
||||||
pages_v3.plugin_manager.discover_plugins()
|
|
||||||
plugin_ids = sorted(
|
|
||||||
pi.get('id') for pi in pages_v3.plugin_manager.get_all_plugin_info()
|
|
||||||
if pi.get('id')
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("search-index: could not enumerate plugins", exc_info=True)
|
|
||||||
|
|
||||||
sig = tuple(plugin_ids)
|
|
||||||
if _SEARCH_INDEX_CACHE['sig'] == sig and _SEARCH_INDEX_CACHE['fields'] is not None:
|
|
||||||
return jsonify({'fields': _SEARCH_INDEX_CACHE['fields']})
|
|
||||||
|
|
||||||
fields = []
|
|
||||||
for tab, label, loader in core_tabs:
|
|
||||||
fields.extend(_extract_settings_fields(_partial_html(loader), tab, label))
|
|
||||||
|
|
||||||
for pid in plugin_ids:
|
|
||||||
info = pages_v3.plugin_manager.get_plugin_info(pid) or {}
|
|
||||||
label = info.get('name', pid)
|
|
||||||
html = _partial_html(lambda pid=pid: _load_plugin_config_partial(pid))
|
|
||||||
fields.extend(_extract_settings_fields(html, pid, label))
|
|
||||||
|
|
||||||
_SEARCH_INDEX_CACHE['sig'] = sig
|
|
||||||
_SEARCH_INDEX_CACHE['fields'] = fields
|
|
||||||
return jsonify({'fields': fields})
|
|
||||||
except Exception:
|
|
||||||
logger.error("Error building settings search index", exc_info=True)
|
|
||||||
return jsonify({'fields': []}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
|
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
|
||||||
def serve_plugin_web_ui(plugin_id, filename):
|
def serve_plugin_web_ui(plugin_id, filename):
|
||||||
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
|
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
|
||||||
|
|||||||
@@ -84,8 +84,6 @@
|
|||||||
[data-theme="dark"] .hover\:text-gray-700:hover { color: #e5e7eb; }
|
[data-theme="dark"] .hover\:text-gray-700:hover { color: #e5e7eb; }
|
||||||
[data-theme="dark"] .hover\:border-gray-300:hover { border-color: #6b7280; }
|
[data-theme="dark"] .hover\:border-gray-300:hover { border-color: #6b7280; }
|
||||||
[data-theme="dark"] .bg-red-100 { background-color: #450a0a; }
|
[data-theme="dark"] .bg-red-100 { background-color: #450a0a; }
|
||||||
[data-theme="dark"] .bg-yellow-100 { background-color: #422006; }
|
|
||||||
[data-theme="dark"] .bg-green-100 { background-color: #022c22; }
|
|
||||||
[data-theme="dark"] .text-red-700 { color: #fca5a5; }
|
[data-theme="dark"] .text-red-700 { color: #fca5a5; }
|
||||||
[data-theme="dark"] .hover\:bg-red-200:hover { background-color: #7f1d1d; }
|
[data-theme="dark"] .hover\:bg-red-200:hover { background-color: #7f1d1d; }
|
||||||
|
|
||||||
@@ -139,14 +137,6 @@ body {
|
|||||||
.text-green-600 { color: #059669; }
|
.text-green-600 { color: #059669; }
|
||||||
.text-red-600 { color: #dc2626; }
|
.text-red-600 { color: #dc2626; }
|
||||||
|
|
||||||
/* Status badge chips (e.g. tools.html's dirty/clean and power-status badges) */
|
|
||||||
.bg-red-100 { background-color: #fee2e2; }
|
|
||||||
.bg-yellow-100 { background-color: #fef9c3; }
|
|
||||||
.bg-green-100 { background-color: #dcfce7; }
|
|
||||||
.text-red-800 { color: #991b1b; }
|
|
||||||
.text-yellow-800 { color: #854d0e; }
|
|
||||||
.text-green-800 { color: #166534; }
|
|
||||||
|
|
||||||
.border-gray-200 { border-color: #e5e7eb; }
|
.border-gray-200 { border-color: #e5e7eb; }
|
||||||
.border-gray-300 { border-color: #d1d5db; }
|
.border-gray-300 { border-color: #d1d5db; }
|
||||||
.border-gray-700 { border-color: #374151; }
|
.border-gray-700 { border-color: #374151; }
|
||||||
@@ -766,153 +756,6 @@ button.bg-white {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================ */
|
|
||||||
/* Settings tooltips (help_tip macro + tooltips.js) */
|
|
||||||
/* ============================================================================ */
|
|
||||||
|
|
||||||
/* The (i) info trigger placed next to a setting label. */
|
|
||||||
.help-tip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 1.15rem;
|
|
||||||
height: 1.15rem;
|
|
||||||
margin-left: 0.375rem;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: help;
|
|
||||||
vertical-align: middle;
|
|
||||||
border-radius: 9999px;
|
|
||||||
transition: color 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-tip:hover,
|
|
||||||
.help-tip:focus-visible {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-tip:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Singleton tooltip panel appended to <body> by tooltips.js. */
|
|
||||||
#ledm-tooltip {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1000;
|
|
||||||
max-width: 20rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
white-space: pre-line; /* renders authored "\n" line breaks */
|
|
||||||
pointer-events: none; /* never steals hover/click from the page */
|
|
||||||
animation: tooltipFade 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ledm-tooltip[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes tooltipFade {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================ */
|
|
||||||
/* Settings search — global header dropdown + per-tab filter */
|
|
||||||
/* ============================================================================ */
|
|
||||||
|
|
||||||
#settings-search-results {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
z-index: 50;
|
|
||||||
padding: 0.25rem;
|
|
||||||
max-height: min(60vh, 24rem);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssr-group {
|
|
||||||
padding: 0.375rem 0.625rem 0.25rem;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssr-option {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.4rem 0.625rem;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssr-option:hover,
|
|
||||||
.ssr-option.active {
|
|
||||||
background: var(--color-info-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssr-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssr-help {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssr-empty {
|
|
||||||
padding: 0.75rem 0.625rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Flash highlight applied to a field after search navigation. */
|
|
||||||
@keyframes settingFlash {
|
|
||||||
0% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
|
|
||||||
25% { box-shadow: 0 0 0 3px var(--color-primary); }
|
|
||||||
100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-flash {
|
|
||||||
animation: settingFlash 1.4s ease;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
#ledm-tooltip { animation: none; }
|
|
||||||
.setting-flash {
|
|
||||||
animation: none;
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Removed .divider and .divider-light - not used anywhere */
|
/* Removed .divider and .divider-light - not used anywhere */
|
||||||
|
|
||||||
/* Enhanced Spacing Utilities - Only unique classes not in main utility section */
|
/* Enhanced Spacing Utilities - Only unique classes not in main utility section */
|
||||||
@@ -1197,26 +1040,3 @@ button.bg-white {
|
|||||||
[data-theme="dark"] .update-banner-dismiss {
|
[data-theme="dark"] .update-banner-dismiss {
|
||||||
color: #93c5fd;
|
color: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Under-voltage / throttling warning banner */
|
|
||||||
.power-warning-banner {
|
|
||||||
background-color: #fef2f2;
|
|
||||||
border-color: #fecaca;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
.power-warning-banner-dismiss {
|
|
||||||
color: #991b1b;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
.power-warning-banner-dismiss:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .power-warning-banner {
|
|
||||||
background-color: #450a0a;
|
|
||||||
border-color: #7f1d1d;
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .power-warning-banner-dismiss {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 || {};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,477 +0,0 @@
|
|||||||
/*
|
|
||||||
* settings-search.js — global settings search + per-tab filter for the v3 UI.
|
|
||||||
*
|
|
||||||
* Two features share one lightweight index built from the same markup the
|
|
||||||
* tooltip work standardizes (.form-group[id^="setting-"] + <label> +
|
|
||||||
* .help-tip[data-tooltip]), so it can never drift from what is rendered:
|
|
||||||
*
|
|
||||||
* 1. Global search (header box): finds settings across ALL tabs, even ones
|
|
||||||
* not yet opened, by fetching a single server-side JSON index
|
|
||||||
* (/v3/settings/search-index) built from all rendered partials.
|
|
||||||
* Clicking a result switches to the tab, waits for the field to load,
|
|
||||||
* then scrolls to and flashes it.
|
|
||||||
* 2. Per-tab filter (the .settings-filter box under a partial title):
|
|
||||||
* hides non-matching fields on the current tab. Delegated, so it keeps
|
|
||||||
* working across HTMX swaps.
|
|
||||||
*
|
|
||||||
* The server owns index generation (including plugin enumeration) and caches
|
|
||||||
* it per installed-plugin set, so the client makes exactly one JSON request.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (window._settingsSearchInit) return;
|
|
||||||
window._settingsSearchInit = true;
|
|
||||||
|
|
||||||
var MAX_RESULTS = 25;
|
|
||||||
|
|
||||||
function debounce(fn, ms) {
|
|
||||||
var t;
|
|
||||||
return function () {
|
|
||||||
var args = arguments, ctx = this;
|
|
||||||
clearTimeout(t);
|
|
||||||
t = setTimeout(function () { fn.apply(ctx, args); }, ms);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// True when every search term is present in the haystack.
|
|
||||||
function termsMatch(hay, terms) {
|
|
||||||
return terms.every(function (t) { return hay.indexOf(t) !== -1; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function textOf(el) {
|
|
||||||
return (el && el.textContent ? el.textContent : '').replace(/\s+/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Index building -------------------------------------------------------
|
|
||||||
|
|
||||||
var buildPromise = null;
|
|
||||||
|
|
||||||
// Fetch the prebuilt index from the server (one literal-URL JSON request)
|
|
||||||
// and cache it for the session. Each entry gets a lowercased `hay` haystack
|
|
||||||
// for matching. The server owns which tabs/plugins are included.
|
|
||||||
function buildIndex(force) {
|
|
||||||
if (window._settingsIndex && !force) return Promise.resolve(window._settingsIndex);
|
|
||||||
if (buildPromise && !force) return buildPromise;
|
|
||||||
|
|
||||||
buildPromise = fetch('/v3/settings/search-index', { headers: { 'X-Requested-With': 'settings-search' } })
|
|
||||||
.then(function (r) { return r.ok ? r.json() : { fields: [] }; })
|
|
||||||
.then(function (data) {
|
|
||||||
var fields = (data && data.fields) || [];
|
|
||||||
fields.forEach(function (f) {
|
|
||||||
f.hay = [f.label, f.help, f.key, f.tabLabel, f.section].join(' ').toLowerCase();
|
|
||||||
});
|
|
||||||
window._settingsIndex = fields;
|
|
||||||
return fields;
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
// Don't cache the failure: clear the in-flight promise so a
|
|
||||||
// later call can retry after a transient fetch error.
|
|
||||||
buildPromise = null;
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
return buildPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Global search UI -----------------------------------------------------
|
|
||||||
|
|
||||||
var input = null, resultsBox = null, activeIndex = -1, currentResults = [];
|
|
||||||
|
|
||||||
function search(q) {
|
|
||||||
q = q.trim().toLowerCase();
|
|
||||||
if (!q) return [];
|
|
||||||
var terms = q.split(/\s+/);
|
|
||||||
var out = [];
|
|
||||||
(window._settingsIndex || []).some(function (entry) {
|
|
||||||
if (termsMatch(entry.hay, terms)) out.push(entry);
|
|
||||||
return out.length >= MAX_RESULTS; // stop once we have enough
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function span(cls, text) {
|
|
||||||
var s = document.createElement('span');
|
|
||||||
s.className = cls;
|
|
||||||
s.textContent = text;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the dropdown with DOM nodes + textContent (never innerHTML) so
|
|
||||||
// setting labels/help can never be interpreted as markup.
|
|
||||||
function renderResults(results) {
|
|
||||||
currentResults = results;
|
|
||||||
activeIndex = -1;
|
|
||||||
resultsBox.textContent = '';
|
|
||||||
if (!results.length) {
|
|
||||||
resultsBox.appendChild(span('ssr-empty', 'No settings found.'));
|
|
||||||
openResults();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var lastTab = null;
|
|
||||||
results.forEach(function (r, i) {
|
|
||||||
if (r.tabLabel !== lastTab) {
|
|
||||||
const group = document.createElement('div');
|
|
||||||
group.className = 'ssr-group';
|
|
||||||
group.textContent = r.tabLabel;
|
|
||||||
resultsBox.appendChild(group);
|
|
||||||
lastTab = r.tabLabel;
|
|
||||||
}
|
|
||||||
var sub = r.section ? (r.section + ' · ') : '';
|
|
||||||
var snippet = r.help ? r.help.split('\n')[0] : '';
|
|
||||||
var opt = document.createElement('button');
|
|
||||||
opt.type = 'button';
|
|
||||||
opt.className = 'ssr-option';
|
|
||||||
opt.setAttribute('role', 'option');
|
|
||||||
opt.id = 'ssr-' + i;
|
|
||||||
opt.setAttribute('data-idx', String(i));
|
|
||||||
opt.appendChild(span('ssr-label', r.label));
|
|
||||||
var helpText = snippet ? (sub + snippet) : (sub ? r.section : '');
|
|
||||||
if (helpText) opt.appendChild(span('ssr-help', helpText));
|
|
||||||
resultsBox.appendChild(opt);
|
|
||||||
});
|
|
||||||
openResults();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openResults() {
|
|
||||||
resultsBox.classList.remove('hidden');
|
|
||||||
// .hidden has no effect without a matching CSS rule (this app's stylesheet
|
|
||||||
// is a hand-picked utility subset, not full Tailwind) - force it directly,
|
|
||||||
// same as the revealNode/collapseNode fallback below.
|
|
||||||
resultsBox.style.display = '';
|
|
||||||
if (input) input.setAttribute('aria-expanded', 'true');
|
|
||||||
}
|
|
||||||
function closeResults() {
|
|
||||||
resultsBox.classList.add('hidden');
|
|
||||||
resultsBox.style.display = 'none';
|
|
||||||
activeIndex = -1;
|
|
||||||
if (input) {
|
|
||||||
input.setAttribute('aria-expanded', 'false');
|
|
||||||
input.removeAttribute('aria-activedescendant');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlight(idx) {
|
|
||||||
var opts = resultsBox.querySelectorAll('.ssr-option');
|
|
||||||
opts.forEach(function (o) { o.classList.remove('active'); });
|
|
||||||
if (idx < 0 || idx >= opts.length) { activeIndex = -1; return; }
|
|
||||||
activeIndex = idx;
|
|
||||||
var el = opts.item(idx);
|
|
||||||
el.classList.add('active');
|
|
||||||
el.scrollIntoView({ block: 'nearest' });
|
|
||||||
input.setAttribute('aria-activedescendant', el.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Navigation to a setting ---------------------------------------------
|
|
||||||
|
|
||||||
function getAppData() {
|
|
||||||
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
|
||||||
if (!appEl) return null;
|
|
||||||
if (appEl._x_dataStack && appEl._x_dataStack[0]) return appEl._x_dataStack[0];
|
|
||||||
if (appEl.__x && appEl.__x.$data) return appEl.__x.$data;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setActiveTab(tab) {
|
|
||||||
var data = getAppData();
|
|
||||||
if (data) { data.activeTab = tab; return true; }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForElement(id, timeout) {
|
|
||||||
return new Promise(function (resolve) {
|
|
||||||
var existing = document.getElementById(id);
|
|
||||||
if (existing) { resolve(existing); return; }
|
|
||||||
var host = document.getElementById('tab-content') || document.body;
|
|
||||||
var done = false;
|
|
||||||
var obs = new MutationObserver(function () {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (el && !done) {
|
|
||||||
done = true;
|
|
||||||
obs.disconnect();
|
|
||||||
resolve(el);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
obs.observe(host, { childList: true, subtree: true });
|
|
||||||
setTimeout(function () {
|
|
||||||
if (!done) { done = true; obs.disconnect(); resolve(document.getElementById(id)); }
|
|
||||||
}, timeout || 6000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNodeHidden(node) {
|
|
||||||
return node.classList.contains('hidden') ||
|
|
||||||
(node.style && node.style.display === 'none') ||
|
|
||||||
window.getComputedStyle(node).display === 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function revealNode(node) {
|
|
||||||
// toggleSection handles the class, inline display, and chevron.
|
|
||||||
if (node.id && typeof window.toggleSection === 'function') {
|
|
||||||
window.toggleSection(node.id);
|
|
||||||
} else {
|
|
||||||
node.classList.remove('hidden');
|
|
||||||
node.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-collapse a nested section the filter previously opened. toggleSection is
|
|
||||||
// state-based, so only toggle while the node is actually visible.
|
|
||||||
function collapseNode(node) {
|
|
||||||
if (isNodeHidden(node)) return;
|
|
||||||
if (node.id && typeof window.toggleSection === 'function') {
|
|
||||||
window.toggleSection(node.id);
|
|
||||||
} else {
|
|
||||||
node.classList.add('hidden');
|
|
||||||
node.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reveal any collapsed nested section (from render_nested_section) so the
|
|
||||||
// target field is actually visible before we scroll to it.
|
|
||||||
function revealAncestors(el) {
|
|
||||||
var node = el.parentElement;
|
|
||||||
while (node && node !== document.body) {
|
|
||||||
if (node.classList && node.classList.contains('nested-content') && isNodeHidden(node)) {
|
|
||||||
revealNode(node);
|
|
||||||
}
|
|
||||||
node = node.parentElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Like revealAncestors, but tags each section we open so the per-tab filter
|
|
||||||
// can restore the original collapsed layout once the query is cleared.
|
|
||||||
function expandNestedFor(el) {
|
|
||||||
var node = el.parentElement;
|
|
||||||
while (node && node !== document.body) {
|
|
||||||
if (node.classList && node.classList.contains('nested-content') && isNodeHidden(node)) {
|
|
||||||
revealNode(node);
|
|
||||||
node.dataset.filterExpanded = '1';
|
|
||||||
}
|
|
||||||
node = node.parentElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flash(el) {
|
|
||||||
el.classList.remove('setting-flash');
|
|
||||||
// force reflow so re-adding the class restarts the animation
|
|
||||||
void el.offsetWidth;
|
|
||||||
el.classList.add('setting-flash');
|
|
||||||
var clear = function () { el.classList.remove('setting-flash'); el.removeEventListener('animationend', clear); };
|
|
||||||
el.addEventListener('animationend', clear);
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToSetting(entry) {
|
|
||||||
closeResults();
|
|
||||||
// Clear the box so it doesn't re-open stale results when refocused.
|
|
||||||
if (input) input.value = '';
|
|
||||||
setActiveTab(entry.tab);
|
|
||||||
waitForElement(entry.anchorId, 6000).then(function (el) {
|
|
||||||
if (!el) return;
|
|
||||||
revealAncestors(el);
|
|
||||||
// Let the tab transition settle before scrolling.
|
|
||||||
setTimeout(function () {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
flash(el);
|
|
||||||
}, 60);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Wire up the header search box ----------------------------------------
|
|
||||||
|
|
||||||
function initSearchBox() {
|
|
||||||
input = document.getElementById('settings-search');
|
|
||||||
resultsBox = document.getElementById('settings-search-results');
|
|
||||||
if (!input || !resultsBox) return;
|
|
||||||
|
|
||||||
// Warm the index in the background so the first search is instant.
|
|
||||||
var warm = function () { buildIndex().catch(function () {}); };
|
|
||||||
if ('requestIdleCallback' in window) {
|
|
||||||
requestIdleCallback(warm, { timeout: 4000 });
|
|
||||||
} else {
|
|
||||||
setTimeout(warm, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener('focus', function () {
|
|
||||||
buildIndex().then(function () {
|
|
||||||
if (input.value.trim()) renderResults(search(input.value));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
input.addEventListener('input', debounce(function () {
|
|
||||||
var q = input.value;
|
|
||||||
if (!q.trim()) { closeResults(); return; }
|
|
||||||
// Focus may have left during the debounce (typed then clicked away);
|
|
||||||
// don't re-open a dropdown the user has already dismissed.
|
|
||||||
if (document.activeElement !== input) return;
|
|
||||||
buildIndex().then(function () {
|
|
||||||
if (document.activeElement === input) renderResults(search(q));
|
|
||||||
});
|
|
||||||
}, 200));
|
|
||||||
|
|
||||||
input.addEventListener('keydown', function (e) {
|
|
||||||
var opts = resultsBox.querySelectorAll('.ssr-option');
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (resultsBox.classList.contains('hidden')) { renderResults(search(input.value)); return; }
|
|
||||||
highlight(Math.min(activeIndex + 1, opts.length - 1));
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
highlight(Math.max(activeIndex - 1, 0));
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
const chosen = currentResults.at(activeIndex >= 0 ? activeIndex : 0);
|
|
||||||
if (chosen) {
|
|
||||||
e.preventDefault();
|
|
||||||
navigateToSetting(chosen);
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
closeResults();
|
|
||||||
input.blur();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
resultsBox.addEventListener('mousedown', function (e) {
|
|
||||||
// mousedown (not click) so it fires before the input blur closes us
|
|
||||||
var opt = e.target.closest('.ssr-option');
|
|
||||||
if (!opt) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const idx = parseInt(opt.getAttribute('data-idx'), 10);
|
|
||||||
const chosen = currentResults.at(idx);
|
|
||||||
if (chosen) navigateToSetting(chosen);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close when a click/tap lands outside the search widget. Capture phase
|
|
||||||
// (the `true`) runs on the way DOWN, before any bubbling stopPropagation
|
|
||||||
// from Alpine/HTMX/widget handlers can swallow the event — a plain
|
|
||||||
// bubble-phase document listener was being eaten and never closing us.
|
|
||||||
// pointerdown also covers touch (Raspberry Pi screen).
|
|
||||||
document.addEventListener('pointerdown', function (e) {
|
|
||||||
if (!input || resultsBox.classList.contains('hidden')) return;
|
|
||||||
var wrap = document.getElementById('settings-search-wrap');
|
|
||||||
var inside = wrap ? wrap.contains(e.target)
|
|
||||||
: (e.target === input || resultsBox.contains(e.target));
|
|
||||||
if (!inside) closeResults();
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
// Reliable dismiss: close shortly after focus leaves the box. Result
|
|
||||||
// selection uses mousedown + preventDefault (focus stays on the input),
|
|
||||||
// so this never fires on a result click; the guard covers focus landing
|
|
||||||
// in the results list (e.g. dragging its scrollbar).
|
|
||||||
input.addEventListener('blur', function () {
|
|
||||||
setTimeout(function () {
|
|
||||||
if (resultsBox && resultsBox.contains(document.activeElement)) return;
|
|
||||||
closeResults();
|
|
||||||
}, 120);
|
|
||||||
});
|
|
||||||
|
|
||||||
// A tab swap (including our own search navigation) should dismiss it.
|
|
||||||
document.body.addEventListener('htmx:afterSwap', closeResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Per-tab filter (delegated) -------------------------------------------
|
|
||||||
|
|
||||||
function filterScope(input) {
|
|
||||||
// Return the nearest tab/content container, or null — never `document`,
|
|
||||||
// which would let the filter hide setting fields across unrelated tabs.
|
|
||||||
return input.closest('.plugin-config-tab') ||
|
|
||||||
input.closest('[id$="-content"]') ||
|
|
||||||
input.closest('.bg-white') ||
|
|
||||||
null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldHay(fg) {
|
|
||||||
var label = textOf(fg.querySelector('label'));
|
|
||||||
var tip = fg.querySelector('.help-tip');
|
|
||||||
var help = tip ? (tip.getAttribute('data-tooltip') || '') : '';
|
|
||||||
var key = fg.getAttribute('data-setting-key') || fg.id.replace(/^setting-/, '');
|
|
||||||
return (label + ' ' + help + ' ' + key).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTabFilter(scope, q) {
|
|
||||||
q = q.trim().toLowerCase();
|
|
||||||
var terms = q ? q.split(/\s+/) : [];
|
|
||||||
var fields = scope.querySelectorAll('.form-group[id^="setting-"]');
|
|
||||||
var anyVisible = false;
|
|
||||||
|
|
||||||
fields.forEach(function (fg) {
|
|
||||||
var show = !terms.length || termsMatch(fieldHay(fg), terms);
|
|
||||||
fg.style.display = show ? '' : 'none';
|
|
||||||
if (show) {
|
|
||||||
anyVisible = true;
|
|
||||||
// Expand any collapsed nested section holding this match so it
|
|
||||||
// is actually visible (plugin tabs default their sections shut).
|
|
||||||
if (terms.length) expandNestedFor(fg);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!terms.length) {
|
|
||||||
// Filter cleared: restore the sections we opened and un-hide every
|
|
||||||
// nested-section wrapper, leaving user-expanded sections untouched.
|
|
||||||
scope.querySelectorAll('.nested-content[data-filter-expanded]').forEach(function (nc) {
|
|
||||||
collapseNode(nc);
|
|
||||||
delete nc.dataset.filterExpanded;
|
|
||||||
});
|
|
||||||
scope.querySelectorAll('.nested-section').forEach(function (ns) { ns.style.display = ''; });
|
|
||||||
} else {
|
|
||||||
// Hide nested-section wrappers whose fields all filtered out.
|
|
||||||
scope.querySelectorAll('.nested-section').forEach(function (ns) {
|
|
||||||
var secFields = ns.querySelectorAll('.form-group[id^="setting-"]');
|
|
||||||
var visible = 0;
|
|
||||||
secFields.forEach(function (f) { if (f.style.display !== 'none') visible++; });
|
|
||||||
ns.style.display = (secFields.length > 0 && visible === 0) ? 'none' : '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide section headings whose settings all got filtered out. A visible
|
|
||||||
// nested-section (plugin tabs) counts as content for its parent heading,
|
|
||||||
// so a heading isn't hidden while a subsection below it still has matches.
|
|
||||||
var nodes = scope.querySelectorAll('h3, h4, .form-group, .nested-section');
|
|
||||||
var headings = [];
|
|
||||||
var current = null;
|
|
||||||
nodes.forEach(function (node) {
|
|
||||||
if (node.tagName === 'H3' || node.tagName === 'H4') {
|
|
||||||
current = { el: node, total: 0, visible: 0 };
|
|
||||||
headings.push(current);
|
|
||||||
} else if (current && node.matches('.form-group[id^="setting-"]')) {
|
|
||||||
current.total++;
|
|
||||||
if (node.style.display !== 'none') current.visible++;
|
|
||||||
} else if (current && node.classList.contains('nested-section')) {
|
|
||||||
current.total++;
|
|
||||||
if (node.style.display !== 'none') current.visible++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
headings.forEach(function (h) {
|
|
||||||
// Only auto-hide headings that exclusively group settings fields.
|
|
||||||
h.el.style.display = (terms.length && h.total > 0 && h.visible === 0) ? 'none' : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle the "no matches" note if the filter box provides one.
|
|
||||||
const wrap = scope.querySelector('.settings-filter-wrap');
|
|
||||||
if (wrap) {
|
|
||||||
const empty = wrap.querySelector('.settings-filter-empty');
|
|
||||||
if (empty) empty.classList.toggle('hidden', !(terms.length && !anyVisible));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('input', function (e) {
|
|
||||||
var box = e.target.closest ? e.target.closest('.settings-filter') : null;
|
|
||||||
if (!box) return;
|
|
||||||
var scope = filterScope(box);
|
|
||||||
if (scope) applyTabFilter(scope, box.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Boot -----------------------------------------------------------------
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initSearchBox);
|
|
||||||
} else {
|
|
||||||
initSearchBox();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose for debugging / programmatic use.
|
|
||||||
window.LEDMatrixSettingsSearch = {
|
|
||||||
buildIndex: buildIndex,
|
|
||||||
navigateToSetting: navigateToSetting
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[SettingsSearch] registered');
|
|
||||||
})();
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
/*
|
|
||||||
* tooltips.js — accessible, delegated tooltip controller for the v3 web UI.
|
|
||||||
*
|
|
||||||
* A single controller handles every `.help-tip` trigger on the page, including
|
|
||||||
* ones inside partials that HTMX swaps in later, with zero per-field wiring.
|
|
||||||
* Triggers are emitted by the `help_tip` Jinja macro (partials/_macros.html) as
|
|
||||||
* <button class="help-tip" data-tooltip="..."><i class="fas fa-circle-info">.
|
|
||||||
*
|
|
||||||
* Behaviour:
|
|
||||||
* - hover (mouse) -> show / hide
|
|
||||||
* - keyboard focus -> show / hide (only for :focus-visible)
|
|
||||||
* - click / tap -> toggle (the touch path)
|
|
||||||
* - Escape / outside click -> hide
|
|
||||||
* The tooltip text is set via textContent (XSS-safe) and supports "\n" line
|
|
||||||
* breaks via CSS `white-space: pre-line`. Styling lives in app.css and uses the
|
|
||||||
* --color-* theme vars, so light/dark mode work automatically.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (window._tooltipsInit) return;
|
|
||||||
window._tooltipsInit = true;
|
|
||||||
|
|
||||||
var panel = null;
|
|
||||||
var currentTrigger = null;
|
|
||||||
|
|
||||||
function getPanel() {
|
|
||||||
if (panel) return panel;
|
|
||||||
panel = document.createElement('div');
|
|
||||||
panel.id = 'ledm-tooltip';
|
|
||||||
panel.setAttribute('role', 'tooltip');
|
|
||||||
panel.hidden = true;
|
|
||||||
document.body.appendChild(panel);
|
|
||||||
return panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function positionPanel(trigger) {
|
|
||||||
var p = getPanel();
|
|
||||||
var margin = 8;
|
|
||||||
var rect = trigger.getBoundingClientRect();
|
|
||||||
var pw = p.offsetWidth;
|
|
||||||
var ph = p.offsetHeight;
|
|
||||||
var vw = document.documentElement.clientWidth;
|
|
||||||
var vh = document.documentElement.clientHeight;
|
|
||||||
|
|
||||||
// Prefer above the trigger; flip below if it would clip the top.
|
|
||||||
var top = rect.top - ph - margin;
|
|
||||||
var placedBelow = false;
|
|
||||||
if (top < margin) {
|
|
||||||
top = rect.bottom + margin;
|
|
||||||
placedBelow = true;
|
|
||||||
}
|
|
||||||
// Keep it on screen vertically as a last resort.
|
|
||||||
if (top + ph > vh - margin) top = Math.max(margin, vh - ph - margin);
|
|
||||||
|
|
||||||
// Center horizontally on the trigger, clamped to the viewport.
|
|
||||||
var left = rect.left + rect.width / 2 - pw / 2;
|
|
||||||
if (left < margin) left = margin;
|
|
||||||
if (left + pw > vw - margin) left = Math.max(margin, vw - pw - margin);
|
|
||||||
|
|
||||||
p.style.top = Math.round(top) + 'px';
|
|
||||||
p.style.left = Math.round(left) + 'px';
|
|
||||||
p.setAttribute('data-placement', placedBelow ? 'below' : 'above');
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(trigger) {
|
|
||||||
var text = trigger.getAttribute('data-tooltip');
|
|
||||||
if (!text) return;
|
|
||||||
var p = getPanel();
|
|
||||||
p.textContent = text;
|
|
||||||
p.hidden = false;
|
|
||||||
// Measure after it is displayed, then position.
|
|
||||||
positionPanel(trigger);
|
|
||||||
trigger.setAttribute('aria-describedby', 'ledm-tooltip');
|
|
||||||
currentTrigger = trigger;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
if (!panel) return;
|
|
||||||
panel.hidden = true;
|
|
||||||
if (currentTrigger) {
|
|
||||||
currentTrigger.removeAttribute('aria-describedby');
|
|
||||||
currentTrigger = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerFrom(target) {
|
|
||||||
return target && target.closest ? target.closest('.help-tip') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Delegated listeners on document (survive HTMX swaps) ---
|
|
||||||
|
|
||||||
document.addEventListener('mouseover', function (e) {
|
|
||||||
var t = triggerFrom(e.target);
|
|
||||||
if (t) show(t);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('mouseout', function (e) {
|
|
||||||
var t = triggerFrom(e.target);
|
|
||||||
if (!t) return;
|
|
||||||
// Ignore moves that stay within the same trigger.
|
|
||||||
var to = e.relatedTarget;
|
|
||||||
if (to && t.contains(to)) return;
|
|
||||||
if (currentTrigger === t) hide();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('focusin', function (e) {
|
|
||||||
var t = triggerFrom(e.target);
|
|
||||||
if (!t) return;
|
|
||||||
// Only auto-show on keyboard focus, so a mouse/touch focus does not
|
|
||||||
// fight the click handler below.
|
|
||||||
var focusVisible;
|
|
||||||
try {
|
|
||||||
focusVisible = t.matches(':focus-visible');
|
|
||||||
} catch { // older browsers without :focus-visible
|
|
||||||
focusVisible = true;
|
|
||||||
}
|
|
||||||
if (focusVisible) show(t);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('focusout', function (e) {
|
|
||||||
var t = triggerFrom(e.target);
|
|
||||||
if (t && currentTrigger === t) hide();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
var t = triggerFrom(e.target);
|
|
||||||
if (t) {
|
|
||||||
// Prevent an enclosing <label> from toggling its control, and
|
|
||||||
// prevent form submission.
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (currentTrigger === t && !getPanel().hidden) {
|
|
||||||
hide();
|
|
||||||
} else {
|
|
||||||
show(t);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Click anywhere else closes an open tooltip.
|
|
||||||
if (panel && !panel.hidden && !panel.contains(e.target)) hide();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Escape' && panel && !panel.hidden) hide();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reposition while visible; close when content is swapped out.
|
|
||||||
window.addEventListener('scroll', function () {
|
|
||||||
if (currentTrigger && panel && !panel.hidden) positionPanel(currentTrigger);
|
|
||||||
}, true);
|
|
||||||
window.addEventListener('resize', function () {
|
|
||||||
if (currentTrigger && panel && !panel.hidden) positionPanel(currentTrigger);
|
|
||||||
});
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function () {
|
|
||||||
// The current trigger may have been removed by the swap.
|
|
||||||
if (currentTrigger && !document.body.contains(currentTrigger)) hide();
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[Tooltips] controller registered');
|
|
||||||
})();
|
|
||||||
@@ -174,16 +174,11 @@
|
|||||||
cell.style.verticalAlign = 'middle';
|
cell.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
if (colType === 'boolean') {
|
if (colType === 'boolean') {
|
||||||
// Boolean: hidden sentinel + visible checkbox, same `name` (so
|
// Boolean: hidden sentinel + visible checkbox
|
||||||
// unchecked boxes still submit "false"). Keep the hidden's value
|
|
||||||
// synced to the checkbox at all times — some form-collection
|
|
||||||
// paths prefer whichever of the two same-named inputs comes
|
|
||||||
// first/last in the DOM, and a stale hidden value previously
|
|
||||||
// caused this field to silently revert to false on every save.
|
|
||||||
const hidden = document.createElement('input');
|
const hidden = document.createElement('input');
|
||||||
hidden.type = 'hidden';
|
hidden.type = 'hidden';
|
||||||
hidden.name = inputName;
|
hidden.name = inputName;
|
||||||
hidden.value = String(Boolean(colValue));
|
hidden.value = 'false';
|
||||||
cell.appendChild(hidden);
|
cell.appendChild(hidden);
|
||||||
|
|
||||||
const cb = document.createElement('input');
|
const cb = document.createElement('input');
|
||||||
@@ -192,9 +187,6 @@
|
|||||||
cb.checked = Boolean(colValue);
|
cb.checked = Boolean(colValue);
|
||||||
cb.value = 'true';
|
cb.value = 'true';
|
||||||
cb.className = 'h-4 w-4 text-blue-600';
|
cb.className = 'h-4 w-4 text-blue-600';
|
||||||
cb.addEventListener('change', () => {
|
|
||||||
hidden.value = String(cb.checked);
|
|
||||||
});
|
|
||||||
cell.appendChild(cb);
|
cell.appendChild(cb);
|
||||||
|
|
||||||
} else if (colType === 'integer' || colType === 'number') {
|
} else if (colType === 'integer' || colType === 'number') {
|
||||||
|
|||||||
@@ -54,18 +54,15 @@
|
|||||||
const logoIdInput = row.querySelector('input[name*=".logo.id"]');
|
const logoIdInput = row.querySelector('input[name*=".logo.id"]');
|
||||||
|
|
||||||
if (nameInput && urlInput) {
|
if (nameInput && urlInput) {
|
||||||
const feedObj = {
|
feeds.push({
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
url: urlInput.value,
|
url: urlInput.value,
|
||||||
enabled: enabledInput ? enabledInput.checked : true
|
enabled: enabledInput ? enabledInput.checked : true,
|
||||||
};
|
logo: logoPathInput || logoIdInput ? {
|
||||||
if (logoPathInput || logoIdInput) {
|
|
||||||
feedObj.logo = {
|
|
||||||
path: logoPathInput ? logoPathInput.value : '',
|
path: logoPathInput ? logoPathInput.value : '',
|
||||||
id: logoIdInput ? logoIdInput.value : ''
|
id: logoIdInput ? logoIdInput.value : ''
|
||||||
};
|
} : null
|
||||||
}
|
});
|
||||||
feeds.push(feedObj);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -882,25 +882,6 @@
|
|||||||
|
|
||||||
<!-- Connection status and theme toggle -->
|
<!-- Connection status and theme toggle -->
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<!-- Global settings search -->
|
|
||||||
<div class="relative hidden sm:block" id="settings-search-wrap">
|
|
||||||
<input id="settings-search"
|
|
||||||
type="text"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-controls="settings-search-results"
|
|
||||||
aria-label="Search settings"
|
|
||||||
placeholder="Search settings…"
|
|
||||||
autocomplete="off"
|
|
||||||
class="form-control text-sm pl-8 pr-4 py-1.5 w-48 lg:w-64">
|
|
||||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs" aria-hidden="true"></i>
|
|
||||||
<div id="settings-search-results"
|
|
||||||
role="listbox"
|
|
||||||
aria-label="Settings search results"
|
|
||||||
class="hidden absolute right-0 mt-1 w-80 max-h-96 overflow-y-auto"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Theme toggle -->
|
<!-- Theme toggle -->
|
||||||
<button id="theme-toggle"
|
<button id="theme-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -932,10 +913,6 @@
|
|||||||
<i class="fas fa-thermometer-half"></i>
|
<i class="fas fa-thermometer-half"></i>
|
||||||
<span>--°C</span>
|
<span>--°C</span>
|
||||||
</span>
|
</span>
|
||||||
<span id="power-stat" class="hidden items-center space-x-1" title="">
|
|
||||||
<i class="fas fa-bolt"></i>
|
|
||||||
<span>Power</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -970,27 +947,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Under-voltage / throttling warning banner -->
|
|
||||||
<div id="power-warning-banner" style="display:none"
|
|
||||||
class="power-warning-banner border-b transition-all duration-300 ease-in-out">
|
|
||||||
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<i class="fas fa-exclamation-triangle text-lg"></i>
|
|
||||||
<span class="text-sm font-medium" id="power-warning-banner-text"
|
|
||||||
aria-live="polite" aria-atomic="true">
|
|
||||||
A power/thermal issue detected right now — the display may flicker or degrade. Check your power supply and cooling.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" onclick="dismissPowerWarningBanner()"
|
|
||||||
class="power-warning-banner-dismiss rounded p-1 transition-colors duration-150"
|
|
||||||
title="Dismiss" aria-label="Dismiss power warning">
|
|
||||||
<i class="fas fa-times text-sm"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
||||||
<!-- Navigation tabs -->
|
<!-- Navigation tabs -->
|
||||||
@@ -1489,89 +1445,6 @@
|
|||||||
window.statsSource.addEventListener('error', window._statsErrorHandler);
|
window.statsSource.addEventListener('error', window._statsErrorHandler);
|
||||||
window.displaySource.addEventListener('error', window._displayErrorHandler);
|
window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||||
|
|
||||||
// Reset any time the currently-active warning clears, so a future
|
|
||||||
// (new) occurrence shows the banner again even if this one was dismissed.
|
|
||||||
window._powerWarningDismissed = false;
|
|
||||||
|
|
||||||
window.dismissPowerWarningBanner = function() {
|
|
||||||
const banner = document.getElementById('power-warning-banner');
|
|
||||||
if (banner) banner.style.display = 'none';
|
|
||||||
window._powerWarningDismissed = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Labels for whichever flags from _get_power_status() are set (pass
|
|
||||||
// suffix='_occurred' for the "happened earlier" variant), used to
|
|
||||||
// build accurate banner/tooltip text instead of hardcoding
|
|
||||||
// "under-voltage" for what may actually be throttling/freq-capping/
|
|
||||||
// thermal limiting.
|
|
||||||
function _activePowerConditionLabels(power, suffix) {
|
|
||||||
suffix = suffix || '_now';
|
|
||||||
const labels = [];
|
|
||||||
if (power['under_voltage' + suffix]) labels.push('under-voltage');
|
|
||||||
if (power['throttled' + suffix]) labels.push('throttling');
|
|
||||||
if (power['freq_capped' + suffix]) labels.push('CPU frequency capped');
|
|
||||||
if (power['soft_temp_limit' + suffix]) labels.push('soft thermal limit');
|
|
||||||
return labels;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePowerStatus(power) {
|
|
||||||
const statEl = document.getElementById('power-stat');
|
|
||||||
const banner = document.getElementById('power-warning-banner');
|
|
||||||
const bannerText = document.getElementById('power-warning-banner-text');
|
|
||||||
|
|
||||||
if (!power) {
|
|
||||||
if (statEl) statEl.classList.add('hidden');
|
|
||||||
if (banner) {
|
|
||||||
banner.style.display = 'none';
|
|
||||||
// Let a future occurrence show the banner again rather
|
|
||||||
// than leaving stale text/visibility from before this
|
|
||||||
// (likely transient) missing-data tick.
|
|
||||||
window._powerWarningDismissed = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeNow = power.under_voltage_now || power.throttled_now ||
|
|
||||||
power.freq_capped_now || power.soft_temp_limit_now;
|
|
||||||
const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred ||
|
|
||||||
power.freq_capped_occurred || power.soft_temp_limit_occurred;
|
|
||||||
|
|
||||||
if (statEl) {
|
|
||||||
statEl.classList.remove('text-red-600', 'text-yellow-600');
|
|
||||||
if (activeNow) {
|
|
||||||
statEl.classList.remove('hidden');
|
|
||||||
statEl.classList.add('flex', 'text-red-600');
|
|
||||||
statEl.title = _activePowerConditionLabels(power).join('/') +
|
|
||||||
' detected right now — check your power supply and cooling';
|
|
||||||
} else if (occurredEarlier) {
|
|
||||||
statEl.classList.remove('hidden');
|
|
||||||
statEl.classList.add('flex', 'text-yellow-600');
|
|
||||||
const occurredLabels = _activePowerConditionLabels(power, '_occurred');
|
|
||||||
statEl.title = (occurredLabels.length ? occurredLabels.join('/') : 'An issue') +
|
|
||||||
' was detected earlier (currently OK)';
|
|
||||||
} else {
|
|
||||||
statEl.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (banner) {
|
|
||||||
if (activeNow) {
|
|
||||||
if (bannerText) {
|
|
||||||
const labels = _activePowerConditionLabels(power);
|
|
||||||
bannerText.textContent = (labels.length ? labels.join('/') : 'A power/thermal issue') +
|
|
||||||
' detected right now — the display may flicker or degrade. Check your power supply and cooling.';
|
|
||||||
}
|
|
||||||
if (!window._powerWarningDismissed) {
|
|
||||||
banner.style.display = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
banner.style.display = 'none';
|
|
||||||
// Let a future occurrence show the banner again.
|
|
||||||
window._powerWarningDismissed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSystemStats(data) {
|
function updateSystemStats(data) {
|
||||||
// Update CPU in header
|
// Update CPU in header
|
||||||
const cpuEl = document.getElementById('cpu-stat');
|
const cpuEl = document.getElementById('cpu-stat');
|
||||||
@@ -1594,9 +1467,6 @@
|
|||||||
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C';
|
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Power (under-voltage / throttling) status in header + banner
|
|
||||||
updatePowerStatus(data.power);
|
|
||||||
|
|
||||||
// Update Overview tab stats (if visible)
|
// Update Overview tab stats (if visible)
|
||||||
const cpuUsageEl = document.getElementById('cpu-usage');
|
const cpuUsageEl = document.getElementById('cpu-usage');
|
||||||
if (cpuUsageEl && data.cpu_percent !== undefined) {
|
if (cpuUsageEl && data.cpu_percent !== undefined) {
|
||||||
@@ -2068,7 +1938,6 @@
|
|||||||
contentEl.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Tools. Please refresh the page.</p></div>';
|
contentEl.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Tools. Please refresh the page.</p></div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -4766,11 +4635,7 @@
|
|||||||
|
|
||||||
<!-- Custom v3 JavaScript -->
|
<!-- Custom v3 JavaScript -->
|
||||||
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
|
||||||
|
|
||||||
<!-- Settings tooltips + settings search -->
|
|
||||||
<script src="{{ url_for('static', filename='v3/js/tooltips.js') }}" defer></script>
|
|
||||||
<script src="{{ url_for('static', filename='v3/js/settings-search.js') }}" defer></script>
|
|
||||||
|
|
||||||
<!-- Modular Plugin Management JavaScript -->
|
<!-- Modular Plugin Management JavaScript -->
|
||||||
<!-- Load utilities first -->
|
<!-- Load utilities first -->
|
||||||
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
{# ============================================================================ #}
|
|
||||||
{# Shared UI macros for the v3 web interface. #}
|
|
||||||
{# #}
|
|
||||||
{# Import at the top of a partial with: #}
|
|
||||||
{# {% import 'v3/partials/_macros.html' as ui %} #}
|
|
||||||
{# #}
|
|
||||||
{# These power the settings tooltips and the settings search feature: #}
|
|
||||||
{# - help_tip(text, label): the (i) info icon whose hover/focus tooltip #}
|
|
||||||
{# explains a setting. This replaces the old always-visible <p> help. #}
|
|
||||||
{# - fg_id(tab, key): stable anchor id for a .form-group so global search #}
|
|
||||||
{# can scroll to it (e.g. "setting-display-brightness"). #}
|
|
||||||
{# - settings_filter(): the per-tab filter box shown under a partial title. #}
|
|
||||||
{# ============================================================================ #}
|
|
||||||
|
|
||||||
{# Info (i) tooltip trigger placed next to a setting label. #}
|
|
||||||
{# `text` supports "\n" line breaks (rendered via CSS white-space: pre-line). #}
|
|
||||||
{# Renders nothing when `text` is empty so callers can pass through schema data. #}
|
|
||||||
{% macro help_tip(text, label='') -%}
|
|
||||||
{%- if text -%}
|
|
||||||
<button type="button" class="help-tip" data-tooltip="{{ text }}"
|
|
||||||
aria-label="{% if label %}Help for {{ label }}{% else %}More information{% endif %}">
|
|
||||||
<i class="fas fa-circle-info" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
{%- endif -%}
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{# Stable anchor id for a settings field's .form-group wrapper. #}
|
|
||||||
{% macro fg_id(tab, key) -%}setting-{{ tab }}-{{ key }}{%- endmacro %}
|
|
||||||
|
|
||||||
{# Per-tab filter box. Place directly under a partial's <h2> title block. #}
|
|
||||||
{# The delegated handler in settings-search.js scopes to the enclosing tab. #}
|
|
||||||
{% macro settings_filter(placeholder='Filter these settings…') -%}
|
|
||||||
<div class="settings-filter-wrap relative mb-6">
|
|
||||||
<input type="text"
|
|
||||||
class="settings-filter form-control text-sm pl-9 pr-4 py-2 w-full"
|
|
||||||
placeholder="{{ placeholder }}"
|
|
||||||
aria-label="Filter settings on this tab"
|
|
||||||
autocomplete="off">
|
|
||||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm" aria-hidden="true"></i>
|
|
||||||
<p class="settings-filter-empty text-sm text-gray-500 mt-3 hidden">No settings match your filter.</p>
|
|
||||||
</div>
|
|
||||||
{%- endmacro %}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
{% import 'v3/partials/_macros.html' as ui %}
|
|
||||||
<div class="space-y-6" id="backup-restore-root">
|
<div class="space-y-6" id="backup-restore-root">
|
||||||
|
|
||||||
<!-- Security warning -->
|
<!-- Security warning -->
|
||||||
@@ -71,12 +70,12 @@
|
|||||||
|
|
||||||
<h3 class="text-sm font-medium text-gray-900 mt-4 mb-2">Choose what to restore</h3>
|
<h3 class="text-sm font-medium text-gray-900 mt-4 mb-2">Choose what to restore</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-700">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-700">
|
||||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-config" checked> <span>Main configuration</span>{{ ui.help_tip('Restore config.json — display settings, schedules, location, and plugin configuration.', 'Main configuration') }}</label>
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-config" checked> <span>Main configuration</span></label>
|
||||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-secrets" checked> <span>API keys (secrets)</span>{{ ui.help_tip('Restore config_secrets.json — your weather, sports, and other service API keys.\nLeave off if you prefer to re-enter keys by hand.', 'API keys') }}</label>
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-secrets" checked> <span>API keys (secrets)</span></label>
|
||||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-wifi" checked> <span>WiFi configuration</span>{{ ui.help_tip('Restore saved WiFi network names and credentials.', 'WiFi configuration') }}</label>
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-wifi" checked> <span>WiFi configuration</span></label>
|
||||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-fonts" checked> <span>User-uploaded fonts</span>{{ ui.help_tip('Restore any custom fonts you uploaded via the Fonts tab.', 'User-uploaded fonts') }}</label>
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-fonts" checked> <span>User-uploaded fonts</span></label>
|
||||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-plugin-uploads" checked> <span>Plugin image uploads</span>{{ ui.help_tip('Restore images and files that plugins let you upload (logos, backgrounds, etc.).', 'Plugin image uploads') }}</label>
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-plugin-uploads" checked> <span>Plugin image uploads</span></label>
|
||||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-reinstall" checked> <span>Reinstall missing plugins</span>{{ ui.help_tip('After restoring, re-download any plugins that were installed in the backup but are missing now.', 'Reinstall missing plugins') }}</label>
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-reinstall" checked> <span>Reinstall missing plugins</span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex gap-2">
|
<div class="mt-4 flex gap-2">
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
{% import 'v3/partials/_macros.html' as ui %}
|
|
||||||
<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">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Display Settings</h2>
|
<h2 class="text-lg font-semibold text-gray-900">Display Settings</h2>
|
||||||
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
|
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ ui.settings_filter('Filter display settings…') }}
|
|
||||||
|
|
||||||
<!-- Hardware status banner: shown when display service is in fallback/simulation mode -->
|
<!-- Hardware status banner: shown when display service is in fallback/simulation mode -->
|
||||||
<div x-data="{ show: false, errorMsg: '' }"
|
<div x-data="{ show: false, errorMsg: '' }"
|
||||||
x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => {
|
x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => {
|
||||||
@@ -40,8 +37,8 @@
|
|||||||
<h3 class="text-md font-medium text-gray-900 mb-4">Hardware Configuration</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-4">Hardware Configuration</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4">
|
||||||
<div class="form-group" id="setting-display-rows" data-setting-key="display.hardware.rows">
|
<div class="form-group">
|
||||||
<label for="rows" class="block text-sm font-medium text-gray-700">Rows{{ ui.help_tip('Number of LED rows on a single panel.\nCommon: 16, 32, or 64. Default: 32. Must match your panel.', 'Rows') }}</label>
|
<label for="rows" class="block text-sm font-medium text-gray-700">Rows</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="rows"
|
id="rows"
|
||||||
name="rows"
|
name="rows"
|
||||||
@@ -49,10 +46,11 @@
|
|||||||
min="1"
|
min="1"
|
||||||
max="64"
|
max="64"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Number of LED rows</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-cols" data-setting-key="display.hardware.cols">
|
<div class="form-group">
|
||||||
<label for="cols" class="block text-sm font-medium text-gray-700">Columns{{ ui.help_tip('Number of LED columns on a single panel.\nCommon: 32 or 64. Default: 64. Must match your panel.', 'Columns') }}</label>
|
<label for="cols" class="block text-sm font-medium text-gray-700">Columns</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="cols"
|
id="cols"
|
||||||
name="cols"
|
name="cols"
|
||||||
@@ -60,10 +58,11 @@
|
|||||||
min="1"
|
min="1"
|
||||||
max="128"
|
max="128"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Number of LED columns</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-chain_length" data-setting-key="display.hardware.chain_length">
|
<div class="form-group">
|
||||||
<label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length{{ ui.help_tip('How many panels are wired end-to-end in one chain.\nDefault: 2. Example: two 64×32 panels chained make a 128×32 display.', 'Chain Length') }}</label>
|
<label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="chain_length"
|
id="chain_length"
|
||||||
name="chain_length"
|
name="chain_length"
|
||||||
@@ -71,10 +70,11 @@
|
|||||||
min="1"
|
min="1"
|
||||||
max="24"
|
max="24"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Number of LED panels chained together</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-parallel" data-setting-key="display.hardware.parallel">
|
<div class="form-group">
|
||||||
<label for="parallel" class="block text-sm font-medium text-gray-700">Parallel{{ ui.help_tip('Number of separate chains driven in parallel from the HAT.\nDefault: 1. The Raspberry Pi supports up to 3 (some HATs allow more).', 'Parallel') }}</label>
|
<label for="parallel" class="block text-sm font-medium text-gray-700">Parallel</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="parallel"
|
id="parallel"
|
||||||
name="parallel"
|
name="parallel"
|
||||||
@@ -82,12 +82,13 @@
|
|||||||
min="1"
|
min="1"
|
||||||
max="4"
|
max="4"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Number of parallel chains</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group" id="setting-display-brightness" data-setting-key="display.hardware.brightness">
|
<div class="form-group">
|
||||||
<label for="brightness" class="block text-sm font-medium text-gray-700">Brightness{{ ui.help_tip('Overall LED brightness (1–100%).\nLower is dimmer, higher is brighter. Recommended: 70–90 indoors, 90–100 in bright rooms.', 'Brightness') }}</label>
|
<label for="brightness" class="block text-sm font-medium text-gray-700">Brightness</label>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<input type="range"
|
<input type="range"
|
||||||
id="brightness"
|
id="brightness"
|
||||||
@@ -98,10 +99,11 @@
|
|||||||
class="flex-1">
|
class="flex-1">
|
||||||
<span id="brightness-value" class="text-sm font-medium w-12">{{ main_config.display.hardware.brightness or 95 }}</span>
|
<span id="brightness-value" class="text-sm font-medium w-12">{{ main_config.display.hardware.brightness or 95 }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">LED brightness: <span id="brightness-display">{{ main_config.display.hardware.brightness or 95 }}</span>%</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-hardware_mapping" data-setting-key="display.hardware.hardware_mapping">
|
<div class="form-group">
|
||||||
<label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping{{ ui.help_tip('How the LED panel is wired to the Pi.\nUse "Adafruit HAT PWM" for an Adafruit HAT/Bonnet with the PWM solder mod; "Adafruit HAT" without it; "Regular" for direct GPIO wiring.', 'Hardware Mapping') }}</label>
|
<label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping</label>
|
||||||
<select id="hardware_mapping" name="hardware_mapping" class="form-control">
|
<select id="hardware_mapping" name="hardware_mapping" class="form-control">
|
||||||
<option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option>
|
<option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option>
|
||||||
<option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option>
|
<option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option>
|
||||||
@@ -112,8 +114,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
|
<div class="form-group">
|
||||||
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
|
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence</label>
|
||||||
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
|
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
|
||||||
<option value="RGB" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RGB" %}selected{% endif %}>RGB</option>
|
<option value="RGB" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RGB" %}selected{% endif %}>RGB</option>
|
||||||
<option value="RBG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RBG" %}selected{% endif %}>RBG</option>
|
<option value="RBG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RBG" %}selected{% endif %}>RBG</option>
|
||||||
@@ -122,10 +124,11 @@
|
|||||||
<option value="BRG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BRG" %}selected{% endif %}>BRG</option>
|
<option value="BRG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BRG" %}selected{% endif %}>BRG</option>
|
||||||
<option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option>
|
<option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Color channel order for your LED panels</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-multiplexing" data-setting-key="display.hardware.multiplexing">
|
<div class="form-group">
|
||||||
<label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing{{ ui.help_tip('Pixel-mapping scheme used by outdoor/specialty panels.\nLeave at 0 (Direct) for most indoor panels. Only change if the image is scrambled — try values until it looks right.', 'Multiplexing') }}</label>
|
<label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing</label>
|
||||||
<select id="multiplexing" name="multiplexing" class="form-control">
|
<select id="multiplexing" name="multiplexing" class="form-control">
|
||||||
<option value="0" {% if main_config.display.hardware.get('multiplexing', 0)|int == 0 %}selected{% endif %}>0 - Direct</option>
|
<option value="0" {% if main_config.display.hardware.get('multiplexing', 0)|int == 0 %}selected{% endif %}>0 - Direct</option>
|
||||||
<option value="1" {% if main_config.display.hardware.get('multiplexing', 0)|int == 1 %}selected{% endif %}>1 - Stripe</option>
|
<option value="1" {% if main_config.display.hardware.get('multiplexing', 0)|int == 1 %}selected{% endif %}>1 - Stripe</option>
|
||||||
@@ -151,19 +154,21 @@
|
|||||||
<option value="21" {% if main_config.display.hardware.get('multiplexing', 0)|int == 21 %}selected{% endif %}>21 - DoubleZMultiplex</option>
|
<option value="21" {% if main_config.display.hardware.get('multiplexing', 0)|int == 21 %}selected{% endif %}>21 - DoubleZMultiplex</option>
|
||||||
<option value="22" {% if main_config.display.hardware.get('multiplexing', 0)|int == 22 %}selected{% endif %}>22 - P4Outdoor-80x40</option>
|
<option value="22" {% if main_config.display.hardware.get('multiplexing', 0)|int == 22 %}selected{% endif %}>22 - P4Outdoor-80x40</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Multiplexing scheme for your LED panels</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-panel_type" data-setting-key="display.hardware.panel_type">
|
<div class="form-group">
|
||||||
<label for="panel_type" class="block text-sm font-medium text-gray-700">Panel Type{{ ui.help_tip('Special initialization for panels with a specific driver chip (e.g. FM6126A, FM6127).\nLeave on Standard unless your panel stays blank or shows only the first pixel.', 'Panel Type') }}</label>
|
<label for="panel_type" class="block text-sm font-medium text-gray-700">Panel Type</label>
|
||||||
<select id="panel_type" name="panel_type" class="form-control">
|
<select id="panel_type" name="panel_type" class="form-control">
|
||||||
<option value="" {% if not main_config.display.hardware.get('panel_type', '') %}selected{% endif %}>Standard</option>
|
<option value="" {% if not main_config.display.hardware.get('panel_type', '') %}selected{% endif %}>Standard</option>
|
||||||
<option value="FM6126A" {% if main_config.display.hardware.get('panel_type', '') == "FM6126A" %}selected{% endif %}>FM6126A</option>
|
<option value="FM6126A" {% if main_config.display.hardware.get('panel_type', '') == "FM6126A" %}selected{% endif %}>FM6126A</option>
|
||||||
<option value="FM6127" {% if main_config.display.hardware.get('panel_type', '') == "FM6127" %}selected{% endif %}>FM6127</option>
|
<option value="FM6127" {% if main_config.display.hardware.get('panel_type', '') == "FM6127" %}selected{% endif %}>FM6127</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Special panel chipset initialization (use Standard unless your panel requires it)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-row_address_type" data-setting-key="display.hardware.row_address_type">
|
<div class="form-group">
|
||||||
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type{{ ui.help_tip('Row addressing scheme used by the panel.\nLeave at 0 (Default) unless your panel needs AB/ABC addressing — a wrong value shows a garbled or shifted image.', 'Row Address Type') }}</label>
|
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type</label>
|
||||||
<select id="row_address_type" name="row_address_type" class="form-control">
|
<select id="row_address_type" name="row_address_type" class="form-control">
|
||||||
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option>
|
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option>
|
||||||
<option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
|
<option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
|
||||||
@@ -171,12 +176,13 @@
|
|||||||
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
|
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
|
||||||
<option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
|
<option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Row addressing scheme — leave at Default (0) unless your panel requires a specific type</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="form-group" id="setting-display-gpio_slowdown" data-setting-key="display.runtime.gpio_slowdown">
|
<div class="form-group">
|
||||||
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown{{ ui.help_tip('Slows the GPIO signal so the panel keeps up.\nGuide: Pi 3 → 1–2, Pi 4 → 2–4, Pi 5 (PIO) → 1–3. Increase if the display shows garbage or flicker; in RIO mode higher values may improve performance.', 'GPIO Slowdown') }}</label>
|
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="gpio_slowdown"
|
id="gpio_slowdown"
|
||||||
name="gpio_slowdown"
|
name="gpio_slowdown"
|
||||||
@@ -184,20 +190,22 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max="10"
|
max="10"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Pi 3: 1–2 · Pi 4: 2–4 · Pi 5 PIO: 1–3. Increase if display shows garbage; in RIO mode higher values may improve performance.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-rp1_rio" data-setting-key="display.runtime.rp1_rio">
|
<div class="form-group">
|
||||||
<label for="rp1_rio" class="block text-sm font-medium text-gray-700">
|
<label for="rp1_rio" class="block text-sm font-medium text-gray-700">
|
||||||
RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span>{{ ui.help_tip('Pi 5 RP1 coprocessor driver mode.\nPIO (0) is the default and uses less CPU. RIO (1) can push a higher refresh rate but inverts the GPIO Slowdown behavior. Ignored on Pi 3/4.', 'RP1 Backend') }}
|
RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span>
|
||||||
</label>
|
</label>
|
||||||
<select id="rp1_rio" name="rp1_rio" class="form-control">
|
<select id="rp1_rio" name="rp1_rio" class="form-control">
|
||||||
<option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 — PIO (default, low CPU)</option>
|
<option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 — PIO (default, low CPU)</option>
|
||||||
<option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 — RIO (higher throughput; slowdown inverted)</option>
|
<option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 — RIO (higher throughput; slowdown inverted)</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Pi 5 RP1 coprocessor mode. Ignored on Pi 3/4.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-scan_mode" data-setting-key="display.hardware.scan_mode">
|
<div class="form-group">
|
||||||
<label for="scan_mode" class="block text-sm font-medium text-gray-700">Scan Mode{{ ui.help_tip('Order rows are refreshed in.\n0 = progressive (default), 1 = interlaced. Change only if you see banding or flicker on certain panels.', 'Scan Mode') }}</label>
|
<label for="scan_mode" class="block text-sm font-medium text-gray-700">Scan Mode</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="scan_mode"
|
id="scan_mode"
|
||||||
name="scan_mode"
|
name="scan_mode"
|
||||||
@@ -205,12 +213,13 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="1"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Scan mode for LED matrix (0-1)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group" id="setting-display-pwm_bits" data-setting-key="display.hardware.pwm_bits">
|
<div class="form-group">
|
||||||
<label for="pwm_bits" class="block text-sm font-medium text-gray-700">PWM Bits{{ ui.help_tip('Color depth per channel (1–11).\nHigher means smoother color but a lower refresh rate; lower means faster refresh with more banding. Default: 11 (this build defaults to 9).', 'PWM Bits') }}</label>
|
<label for="pwm_bits" class="block text-sm font-medium text-gray-700">PWM Bits</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="pwm_bits"
|
id="pwm_bits"
|
||||||
name="pwm_bits"
|
name="pwm_bits"
|
||||||
@@ -218,10 +227,11 @@
|
|||||||
min="1"
|
min="1"
|
||||||
max="11"
|
max="11"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">PWM bits for brightness control (1-11)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-pwm_dither_bits" data-setting-key="display.hardware.pwm_dither_bits">
|
<div class="form-group">
|
||||||
<label for="pwm_dither_bits" class="block text-sm font-medium text-gray-700">PWM Dither Bits{{ ui.help_tip('Time-dithering to gain apparent color depth (0–4).\nDefault: 0. Raising it can smooth gradients at the cost of a slightly lower refresh rate.', 'PWM Dither Bits') }}</label>
|
<label for="pwm_dither_bits" class="block text-sm font-medium text-gray-700">PWM Dither Bits</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="pwm_dither_bits"
|
id="pwm_dither_bits"
|
||||||
name="pwm_dither_bits"
|
name="pwm_dither_bits"
|
||||||
@@ -229,12 +239,13 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max="4"
|
max="4"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">PWM dither bits (0-4)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group" id="setting-display-pwm_lsb_nanoseconds" data-setting-key="display.hardware.pwm_lsb_nanoseconds">
|
<div class="form-group">
|
||||||
<label for="pwm_lsb_nanoseconds" class="block text-sm font-medium text-gray-700">PWM LSB Nanoseconds{{ ui.help_tip('Base time for the least-significant color bit (50–500 ns).\nDefault: 130. Raising it can reduce flicker on some panels but lowers the maximum refresh rate.', 'PWM LSB Nanoseconds') }}</label>
|
<label for="pwm_lsb_nanoseconds" class="block text-sm font-medium text-gray-700">PWM LSB Nanoseconds</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="pwm_lsb_nanoseconds"
|
id="pwm_lsb_nanoseconds"
|
||||||
name="pwm_lsb_nanoseconds"
|
name="pwm_lsb_nanoseconds"
|
||||||
@@ -242,10 +253,11 @@
|
|||||||
min="50"
|
min="50"
|
||||||
max="500"
|
max="500"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">PWM LSB nanoseconds (50-500)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-limit_refresh_rate_hz" data-setting-key="display.hardware.limit_refresh_rate_hz">
|
<div class="form-group">
|
||||||
<label for="limit_refresh_rate_hz" class="block text-sm font-medium text-gray-700">Limit Refresh Rate (Hz){{ ui.help_tip('Caps the panel refresh rate (1–1000 Hz).\nDefault: 120. A steady cap reduces flicker in camera recordings and keeps timing consistent. Set higher or to the max your panel supports for the smoothest motion.', 'Limit Refresh Rate') }}</label>
|
<label for="limit_refresh_rate_hz" class="block text-sm font-medium text-gray-700">Limit Refresh Rate (Hz)</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="limit_refresh_rate_hz"
|
id="limit_refresh_rate_hz"
|
||||||
name="limit_refresh_rate_hz"
|
name="limit_refresh_rate_hz"
|
||||||
@@ -253,6 +265,7 @@
|
|||||||
min="1"
|
min="1"
|
||||||
max="1000"
|
max="1000"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Limit refresh rate in Hz (1-1000)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +276,7 @@
|
|||||||
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
|
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="form-group" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
|
<div class="form-group">
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
id="double_sided_enabled"
|
id="double_sided_enabled"
|
||||||
@@ -272,12 +285,12 @@
|
|||||||
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
|
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
|
||||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||||||
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
|
|
||||||
</label>
|
</label>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Mirror one screen across all panels.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
|
<div class="form-group">
|
||||||
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (2–8).\nMust divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
|
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="double_sided_copies"
|
id="double_sided_copies"
|
||||||
name="double_sided_copies"
|
name="double_sided_copies"
|
||||||
@@ -285,14 +298,16 @@
|
|||||||
min="2"
|
min="2"
|
||||||
max="8"
|
max="8"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Number of identical screens. Must divide the panel evenly.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-double_sided_axis" data-setting-key="display.double_sided.axis">
|
<div class="form-group">
|
||||||
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis{{ ui.help_tip('Direction the display is divided into copies.\nHorizontal splits along the chained panels (side by side); Vertical splits along parallel chains (stacked).', 'Split Axis') }}</label>
|
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis</label>
|
||||||
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
|
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
|
||||||
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal — chained panels (side by side)</option>
|
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal — chained panels (side by side)</option>
|
||||||
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical — parallel chains (stacked)</option>
|
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical — parallel chains (stacked)</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Horizontal splits the chain; vertical splits parallel outputs.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,7 +317,7 @@
|
|||||||
<h3 class="text-md font-medium text-gray-900 mb-4">Display Options</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-4">Display Options</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group" id="setting-display-disable_hardware_pulsing" data-setting-key="display.hardware.disable_hardware_pulsing">
|
<div class="form-group">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="disable_hardware_pulsing"
|
name="disable_hardware_pulsing"
|
||||||
@@ -310,11 +325,10 @@
|
|||||||
{% if main_config.display.hardware.disable_hardware_pulsing %}checked{% endif %}
|
{% if main_config.display.hardware.disable_hardware_pulsing %}checked{% endif %}
|
||||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
<span class="ml-2 text-sm font-medium text-gray-900">Disable Hardware Pulsing</span>
|
<span class="ml-2 text-sm font-medium text-gray-900">Disable Hardware Pulsing</span>
|
||||||
{{ ui.help_tip('Turn off hardware PWM pulsing.\nEnable this if the Pi audio is in use or you hear buzzing / see instability. Slightly increases CPU usage.', 'Disable Hardware Pulsing') }}
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-inverse_colors" data-setting-key="display.hardware.inverse_colors">
|
<div class="form-group">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="inverse_colors"
|
name="inverse_colors"
|
||||||
@@ -322,11 +336,10 @@
|
|||||||
{% if main_config.display.hardware.inverse_colors %}checked{% endif %}
|
{% if main_config.display.hardware.inverse_colors %}checked{% endif %}
|
||||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
<span class="ml-2 text-sm font-medium text-gray-900">Inverse Colors</span>
|
<span class="ml-2 text-sm font-medium text-gray-900">Inverse Colors</span>
|
||||||
{{ ui.help_tip('Invert every color the panel shows.\nDefault: off. Only needed for panels wired with inverted color logic.', 'Inverse Colors') }}
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-show_refresh_rate" data-setting-key="display.hardware.show_refresh_rate">
|
<div class="form-group">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="show_refresh_rate"
|
name="show_refresh_rate"
|
||||||
@@ -334,11 +347,10 @@
|
|||||||
{% if main_config.display.hardware.show_refresh_rate %}checked{% endif %}
|
{% if main_config.display.hardware.show_refresh_rate %}checked{% endif %}
|
||||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
<span class="ml-2 text-sm font-medium text-gray-900">Show Refresh Rate</span>
|
<span class="ml-2 text-sm font-medium text-gray-900">Show Refresh Rate</span>
|
||||||
{{ ui.help_tip('Overlay the live panel refresh rate on the display.\nUseful for tuning GPIO Slowdown and PWM settings; turn off for normal use.', 'Show Refresh Rate') }}
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-use_short_date_format" data-setting-key="display.use_short_date_format">
|
<div class="form-group">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="use_short_date_format"
|
name="use_short_date_format"
|
||||||
@@ -346,7 +358,6 @@
|
|||||||
{% if main_config.display.use_short_date_format %}checked{% endif %}
|
{% if main_config.display.use_short_date_format %}checked{% endif %}
|
||||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
<span class="ml-2 text-sm font-medium text-gray-900">Use Short Date Format</span>
|
<span class="ml-2 text-sm font-medium text-gray-900">Use Short Date Format</span>
|
||||||
{{ ui.help_tip('Show dates in a compact form (e.g. 7/8 instead of July 8).\nHandy on narrow displays where space is tight.', 'Use Short Date Format') }}
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,8 +366,8 @@
|
|||||||
<div class="mt-6 pt-4 border-t border-gray-300">
|
<div class="mt-6 pt-4 border-t border-gray-300">
|
||||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Dynamic Duration</h4>
|
<h4 class="text-sm font-medium text-gray-900 mb-3">Dynamic Duration</h4>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group" id="setting-display-max_dynamic_duration_seconds" data-setting-key="display.dynamic_duration.max_duration_seconds">
|
<div class="form-group">
|
||||||
<label for="max_dynamic_duration_seconds" class="block text-sm font-medium text-gray-700">Max Dynamic Duration (seconds){{ ui.help_tip('Ceiling on how long a plugin may extend its own on-screen time (30–1800s).\nDefault: 180. Plugins with live content (e.g. a game in progress) can request extra time up to this limit.', 'Max Dynamic Duration') }}</label>
|
<label for="max_dynamic_duration_seconds" class="block text-sm font-medium text-gray-700">Max Dynamic Duration (seconds)</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="max_dynamic_duration_seconds"
|
id="max_dynamic_duration_seconds"
|
||||||
name="max_dynamic_duration_seconds"
|
name="max_dynamic_duration_seconds"
|
||||||
@@ -364,6 +375,7 @@
|
|||||||
min="30"
|
min="30"
|
||||||
max="1800"
|
max="1800"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Maximum time plugins can extend display duration (30-1800 seconds)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,8 +405,8 @@
|
|||||||
<!-- Vegas Settings (shown when enabled) -->
|
<!-- Vegas Settings (shown when enabled) -->
|
||||||
<div id="vegas_scroll_settings" class="space-y-4" style="{% if not main_config.display.get('vegas_scroll', {}).get('enabled', false) %}display: none;{% endif %}">
|
<div id="vegas_scroll_settings" class="space-y-4" style="{% if not main_config.display.get('vegas_scroll', {}).get('enabled', false) %}display: none;{% endif %}">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group" id="setting-display-vegas_scroll_speed" data-setting-key="display.vegas_scroll.scroll_speed">
|
<div class="form-group">
|
||||||
<label for="vegas_scroll_speed" class="block text-sm font-medium text-gray-700">Scroll Speed (pixels/second){{ ui.help_tip('How fast the Vegas ticker scrolls (10–200 px/s).\nDefault: 50. Higher is faster but harder to read.', 'Scroll Speed') }}</label>
|
<label for="vegas_scroll_speed" class="block text-sm font-medium text-gray-700">Scroll Speed (pixels/second)</label>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<input type="range"
|
<input type="range"
|
||||||
id="vegas_scroll_speed"
|
id="vegas_scroll_speed"
|
||||||
@@ -406,10 +418,11 @@
|
|||||||
class="flex-1">
|
class="flex-1">
|
||||||
<span id="vegas_scroll_speed_value" class="text-sm font-medium w-12">{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}</span>
|
<span id="vegas_scroll_speed_value" class="text-sm font-medium w-12">{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Speed of the scrolling ticker (10-200 px/s)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
|
<div class="form-group">
|
||||||
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0–128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', 'Separator Width') }}</label>
|
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels)</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="vegas_separator_width"
|
id="vegas_separator_width"
|
||||||
name="vegas_separator_width"
|
name="vegas_separator_width"
|
||||||
@@ -417,26 +430,29 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max="128"
|
max="128"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Gap between plugin content blocks (0-128 px)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
|
<div class="form-group">
|
||||||
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
|
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS</label>
|
||||||
<select id="vegas_target_fps" name="vegas_target_fps" class="form-control">
|
<select id="vegas_target_fps" name="vegas_target_fps" class="form-control">
|
||||||
<option value="60" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 60 %}selected{% endif %}>60 FPS (Lower CPU)</option>
|
<option value="60" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 60 %}selected{% endif %}>60 FPS (Lower CPU)</option>
|
||||||
<option value="90" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 90 %}selected{% endif %}>90 FPS (Balanced)</option>
|
<option value="90" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 90 %}selected{% endif %}>90 FPS (Balanced)</option>
|
||||||
<option value="125" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 125 %}selected{% endif %}>125 FPS (Smoothest)</option>
|
<option value="125" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 125 %}selected{% endif %}>125 FPS (Smoothest)</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Higher FPS = smoother scroll, more CPU usage</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-vegas_buffer_ahead" data-setting-key="display.vegas_scroll.buffer_ahead">
|
<div class="form-group">
|
||||||
<label for="vegas_buffer_ahead" class="block text-sm font-medium text-gray-700">Buffer Ahead{{ ui.help_tip('How many upcoming plugins to pre-render so the scroll never stalls.\nDefault: 2 (recommended). More uses extra memory; less saves memory but risks hitches.', 'Buffer Ahead') }}</label>
|
<label for="vegas_buffer_ahead" class="block text-sm font-medium text-gray-700">Buffer Ahead</label>
|
||||||
<select id="vegas_buffer_ahead" name="vegas_buffer_ahead" class="form-control">
|
<select id="vegas_buffer_ahead" name="vegas_buffer_ahead" class="form-control">
|
||||||
<option value="1" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 1 %}selected{% endif %}>1 Plugin (Less memory)</option>
|
<option value="1" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 1 %}selected{% endif %}>1 Plugin (Less memory)</option>
|
||||||
<option value="2" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 2 %}selected{% endif %}>2 Plugins (Recommended)</option>
|
<option value="2" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 2 %}selected{% endif %}>2 Plugins (Recommended)</option>
|
||||||
<option value="3" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 3 %}selected{% endif %}>3 Plugins (More buffer)</option>
|
<option value="3" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 3 %}selected{% endif %}>3 Plugins (More buffer)</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">How many plugins to pre-load ahead</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -469,17 +485,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group" id="setting-display-sync_role" data-setting-key="sync.role">
|
<div class="form-group">
|
||||||
<label for="sync_role" class="block text-sm font-medium text-gray-700">Role{{ ui.help_tip('This unit\'s part in a two-display setup.\nStandalone = sync off. Set one Pi to Leader (drives the scroll) and the other to Follower (receives frames). Restart required after changing.', 'Sync Role') }}</label>
|
<label for="sync_role" class="block text-sm font-medium text-gray-700">Role</label>
|
||||||
<select id="sync_role" name="sync_role" class="form-control" onchange="updateSyncUI()">
|
<select id="sync_role" name="sync_role" class="form-control" onchange="updateSyncUI()">
|
||||||
<option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option>
|
<option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option>
|
||||||
<option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option>
|
<option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option>
|
||||||
<option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option>
|
<option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Set Leader on one Pi, Follower on the other. Restart required after changing.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-sync_port" data-setting-key="sync.port">
|
<div class="form-group">
|
||||||
<label for="sync_port" class="block text-sm font-medium text-gray-700">UDP Port{{ ui.help_tip('UDP port the two displays use to exchange frames (1024–65535).\nDefault: 5765. Must match on both Pis. If the ufw firewall is active, allow it with: sudo ufw allow ' ~ main_config.get('sync', {}).get('port', 5765) ~ '/udp', 'Sync UDP Port') }}</label>
|
<label for="sync_port" class="block text-sm font-medium text-gray-700">UDP Port</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="sync_port"
|
id="sync_port"
|
||||||
name="sync_port"
|
name="sync_port"
|
||||||
@@ -487,14 +504,19 @@
|
|||||||
min="1024"
|
min="1024"
|
||||||
max="65535"
|
max="65535"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
Must match on both Pis. If ufw is active:
|
||||||
|
<code class="text-xs bg-gray-200 px-1 rounded">sudo ufw allow {{ main_config.get('sync', {}).get('port', 5765) }}/udp</code>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-sync_follower_position" data-setting-key="sync.follower_position" style="display:none">
|
<div class="form-group" id="sync_position_group" style="display:none">
|
||||||
<label for="sync_follower_position" class="block text-sm font-medium text-gray-700">Position{{ ui.help_tip('Which side of the leader this follower display sits on.\nSets whether this unit shows the left or right half of the extended scroll.', 'Follower Position') }}</label>
|
<label for="sync_follower_position" class="block text-sm font-medium text-gray-700">Position</label>
|
||||||
<select id="sync_follower_position" name="sync_follower_position" class="form-control">
|
<select id="sync_follower_position" name="sync_follower_position" class="form-control">
|
||||||
<option value="left" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'left' %}selected{% endif %}>Left of leader</option>
|
<option value="left" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'left' %}selected{% endif %}>Left of leader</option>
|
||||||
<option value="right" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'right' %}selected{% endif %}>Right of leader</option>
|
<option value="right" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'right' %}selected{% endif %}>Right of leader</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Which side of the leader display this unit sits on.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -773,7 +795,7 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
|
|||||||
function updateSyncUI() {
|
function updateSyncUI() {
|
||||||
const role = document.getElementById('sync_role').value;
|
const role = document.getElementById('sync_role').value;
|
||||||
const bar = document.getElementById('sync_status_bar');
|
const bar = document.getElementById('sync_status_bar');
|
||||||
const posGroup = document.getElementById('setting-display-sync_follower_position');
|
const posGroup = document.getElementById('sync_position_group');
|
||||||
if (role === 'standalone') {
|
if (role === 'standalone') {
|
||||||
bar.classList.add('hidden');
|
bar.classList.add('hidden');
|
||||||
document.getElementById('sync_error_detail').classList.add('hidden');
|
document.getElementById('sync_error_detail').classList.add('hidden');
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
{% import 'v3/partials/_macros.html' as ui %}
|
|
||||||
<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">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Display Durations</h2>
|
<h2 class="text-lg font-semibold text-gray-900">Display Durations</h2>
|
||||||
<p class="mt-1 text-sm text-gray-600">Configure how long each screen is shown before switching. Values in seconds.</p>
|
<p class="mt-1 text-sm text-gray-600">Configure how long each screen is shown before switching. Values in seconds.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ ui.settings_filter() }}
|
|
||||||
|
|
||||||
<form hx-post="/api/v3/config/main"
|
<form hx-post="/api/v3/config/main"
|
||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
hx-headers='{"Content-Type": "application/json"}'
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
@@ -18,9 +15,9 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{% for key, value in main_config.display.display_durations.items() %}
|
{% for key, value in main_config.display.display_durations.items() %}
|
||||||
<div class="form-group" id="setting-durations-{{ key }}" data-setting-key="display.display_durations.{{ key }}">
|
<div class="form-group">
|
||||||
<label for="duration_{{ key }}" class="block text-sm font-medium text-gray-700">
|
<label for="duration_{{ key }}" class="block text-sm font-medium text-gray-700">
|
||||||
{{ key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600. Currently ' ~ value ~ 's.', key | replace('_', ' ') | title) }}
|
{{ key | replace('_', ' ') | title }}
|
||||||
</label>
|
</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="duration_{{ key }}"
|
id="duration_{{ key }}"
|
||||||
@@ -29,6 +26,7 @@
|
|||||||
min="5"
|
min="5"
|
||||||
max="600"
|
max="600"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">{{ value }} seconds</p>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
{% import 'v3/partials/_macros.html' as ui %}
|
|
||||||
<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">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">General Settings</h2>
|
<h2 class="text-lg font-semibold text-gray-900">General Settings</h2>
|
||||||
<p class="mt-1 text-sm text-gray-600">Configure general system settings and location information.</p>
|
<p class="mt-1 text-sm text-gray-600">Configure general system settings and location information.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ ui.settings_filter() }}
|
|
||||||
|
|
||||||
<form hx-post="/api/v3/config/main"
|
<form hx-post="/api/v3/config/main"
|
||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
hx-headers='{"Content-Type": "application/json"}'
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
@@ -32,7 +29,7 @@
|
|||||||
class="space-y-6">
|
class="space-y-6">
|
||||||
|
|
||||||
<!-- Web Display Autostart -->
|
<!-- Web Display Autostart -->
|
||||||
<div class="form-group" id="setting-general-web_display_autostart" data-setting-key="web_display_autostart">
|
<div class="form-group">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="web_display_autostart"
|
name="web_display_autostart"
|
||||||
@@ -40,14 +37,15 @@
|
|||||||
{% if main_config.web_display_autostart %}checked{% endif %}
|
{% if main_config.web_display_autostart %}checked{% endif %}
|
||||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
<span class="ml-2 text-sm font-medium text-gray-900">Web Display Autostart</span>
|
<span class="ml-2 text-sm font-medium text-gray-900">Web Display Autostart</span>
|
||||||
{{ ui.help_tip('Automatically start the web interface when the device boots.\nDefault: on. Turn off if you launch the web UI manually or run headless.', 'Web Display Autostart') }}
|
|
||||||
</label>
|
</label>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Start the web interface on boot for easier access.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timezone -->
|
<!-- Timezone -->
|
||||||
<div class="form-group" id="setting-general-timezone" data-setting-key="timezone">
|
<div class="form-group">
|
||||||
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
|
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone</label>
|
||||||
<div id="timezone_container" class="mt-1"></div>
|
<div id="timezone_container" class="mt-1"></div>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Select your timezone for time-based features and scheduling.</p>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
@@ -82,8 +80,8 @@
|
|||||||
|
|
||||||
<!-- Location Information -->
|
<!-- Location Information -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
|
||||||
<div class="form-group" id="setting-general-city" data-setting-key="location.city">
|
<div class="form-group">
|
||||||
<label for="city" class="block text-sm font-medium text-gray-700">City{{ ui.help_tip('City used for weather, sunrise/sunset, and other location-based content.\nExample: Dallas.', 'City') }}</label>
|
<label for="city" class="block text-sm font-medium text-gray-700">City</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="city"
|
id="city"
|
||||||
name="city"
|
name="city"
|
||||||
@@ -91,8 +89,8 @@
|
|||||||
class="form-control">
|
class="form-control">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-general-state" data-setting-key="location.state">
|
<div class="form-group">
|
||||||
<label for="state" class="block text-sm font-medium text-gray-700">State{{ ui.help_tip('State or region for your location.\nExample: Texas. Improves location-lookup accuracy.', 'State') }}</label>
|
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="state"
|
id="state"
|
||||||
name="state"
|
name="state"
|
||||||
@@ -100,8 +98,8 @@
|
|||||||
class="form-control">
|
class="form-control">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-general-country" data-setting-key="location.country">
|
<div class="form-group">
|
||||||
<label for="country" class="block text-sm font-medium text-gray-700">Country{{ ui.help_tip('Country code or name for your location.\nExample: US. Used with City and State for weather and geolocation.', 'Country') }}</label>
|
<label for="country" class="block text-sm font-medium text-gray-700">Country</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="country"
|
id="country"
|
||||||
name="country"
|
name="country"
|
||||||
@@ -117,7 +115,7 @@
|
|||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Auto Discover -->
|
<!-- Auto Discover -->
|
||||||
<div class="form-group" id="setting-general-auto_discover" data-setting-key="plugin_system.auto_discover">
|
<div class="form-group">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="auto_discover"
|
name="auto_discover"
|
||||||
@@ -125,12 +123,12 @@
|
|||||||
{% if main_config.get('plugin_system', {}).get('auto_discover', True) %}checked{% endif %}
|
{% if main_config.get('plugin_system', {}).get('auto_discover', True) %}checked{% endif %}
|
||||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
<span class="ml-2 text-sm font-medium text-gray-900">Auto Discover Plugins</span>
|
<span class="ml-2 text-sm font-medium text-gray-900">Auto Discover Plugins</span>
|
||||||
{{ ui.help_tip('Scan the plugins directory for installed plugins each time the service starts.\nDefault: on. Leave on unless you manage plugins manually.', 'Auto Discover Plugins') }}
|
|
||||||
</label>
|
</label>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Automatically discover plugins in the plugins directory on startup.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto Load Enabled -->
|
<!-- Auto Load Enabled -->
|
||||||
<div class="form-group" id="setting-general-auto_load_enabled" data-setting-key="plugin_system.auto_load_enabled">
|
<div class="form-group">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="auto_load_enabled"
|
name="auto_load_enabled"
|
||||||
@@ -138,12 +136,12 @@
|
|||||||
{% if main_config.get('plugin_system', {}).get('auto_load_enabled', True) %}checked{% endif %}
|
{% if main_config.get('plugin_system', {}).get('auto_load_enabled', True) %}checked{% endif %}
|
||||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
<span class="ml-2 text-sm font-medium text-gray-900">Auto Load Enabled Plugins</span>
|
<span class="ml-2 text-sm font-medium text-gray-900">Auto Load Enabled Plugins</span>
|
||||||
{{ ui.help_tip('Load every plugin marked enabled in the configuration at startup.\nDefault: on. Turn off to keep plugins installed but dormant.', 'Auto Load Enabled Plugins') }}
|
|
||||||
</label>
|
</label>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Automatically load plugins that are enabled in configuration.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Development Mode -->
|
<!-- Development Mode -->
|
||||||
<div class="form-group" id="setting-general-development_mode" data-setting-key="plugin_system.development_mode">
|
<div class="form-group">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="development_mode"
|
name="development_mode"
|
||||||
@@ -151,19 +149,20 @@
|
|||||||
{% if main_config.get('plugin_system', {}).get('development_mode', False) %}checked{% endif %}
|
{% if main_config.get('plugin_system', {}).get('development_mode', False) %}checked{% endif %}
|
||||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
<span class="ml-2 text-sm font-medium text-gray-900">Development Mode</span>
|
<span class="ml-2 text-sm font-medium text-gray-900">Development Mode</span>
|
||||||
{{ ui.help_tip('Enable verbose logging and developer features for plugin debugging.\nDefault: off. Keep off for normal use — it increases log volume.', 'Development Mode') }}
|
|
||||||
</label>
|
</label>
|
||||||
|
<p class="mt-1 text-gray-600 text-sm">Enable verbose logging and development features for plugin debugging.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plugins Directory -->
|
<!-- Plugins Directory -->
|
||||||
<div class="form-group" id="setting-general-plugins_directory" data-setting-key="plugin_system.plugins_directory">
|
<div class="form-group">
|
||||||
<label for="plugins_directory" class="block text-sm font-medium text-gray-700">Plugins Directory{{ ui.help_tip('Folder (relative to the project root) where plugins are stored.\nDefault: plugin-repos. Only change this if you keep plugins in a custom location.', 'Plugins Directory') }}</label>
|
<label for="plugins_directory" class="block text-sm font-medium text-gray-700">Plugins Directory</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="plugins_directory"
|
id="plugins_directory"
|
||||||
name="plugins_directory"
|
name="plugins_directory"
|
||||||
value="{{ main_config.get('plugin_system', {}).get('plugins_directory', 'plugin-repos') }}"
|
value="{{ main_config.get('plugin_system', {}).get('plugins_directory', 'plugin-repos') }}"
|
||||||
placeholder="plugin-repos"
|
placeholder="plugin-repos"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Directory where plugins are stored (relative to project root).</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
{# Plugin Configuration Partial - Server-side rendered form #}
|
{# Plugin Configuration Partial - Server-side rendered form #}
|
||||||
{# This template is loaded via HTMX when a plugin tab is clicked #}
|
{# This template is loaded via HTMX when a plugin tab is clicked #}
|
||||||
|
|
||||||
{% import 'v3/partials/_macros.html' as ui %}
|
|
||||||
|
|
||||||
{# ===== MACROS FOR FORM FIELD GENERATION ===== #}
|
{# ===== MACROS FOR FORM FIELD GENERATION ===== #}
|
||||||
|
|
||||||
{# Render a single form field based on schema type #}
|
{# Render a single form field based on schema type #}
|
||||||
@@ -20,8 +18,9 @@
|
|||||||
{% if obj_widget == 'schedule-picker' %}
|
{% if obj_widget == 'schedule-picker' %}
|
||||||
{# Schedule picker widget - renders enable/mode/times UI #}
|
{# Schedule picker widget - renders enable/mode/times UI #}
|
||||||
{% set obj_value = value if value is not none else {} %}
|
{% set obj_value = value if value is not none else {} %}
|
||||||
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
|
<div class="form-group mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}</label>
|
||||||
|
{% if description %}<p class="text-sm text-gray-500 mb-2">{{ description }}</p>{% endif %}
|
||||||
<div id="{{ field_id }}_container" class="schedule-picker-container mt-1"></div>
|
<div id="{{ field_id }}_container" class="schedule-picker-container mt-1"></div>
|
||||||
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "'") }}'>
|
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "'") }}'>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,8 +46,9 @@
|
|||||||
{% elif obj_widget == 'time-range' %}
|
{% elif obj_widget == 'time-range' %}
|
||||||
{# Time range widget - renders start/end time inputs #}
|
{# Time range widget - renders start/end time inputs #}
|
||||||
{% set obj_value = value if value is not none else {} %}
|
{% set obj_value = value if value is not none else {} %}
|
||||||
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
|
<div class="form-group mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}</label>
|
||||||
|
{% if description %}<p class="text-sm text-gray-500 mb-2">{{ description }}</p>{% endif %}
|
||||||
<div id="{{ field_id }}_container" class="time-range-container mt-1"></div>
|
<div id="{{ field_id }}_container" class="time-range-container mt-1"></div>
|
||||||
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "'") }}'>
|
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "'") }}'>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,11 +75,15 @@
|
|||||||
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
|
<div class="form-group mb-4">
|
||||||
<label for="{{ field_id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="{{ field_id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{{ label }}{{ ui.help_tip(description, label) }}
|
{{ label }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{% if description %}
|
||||||
|
<p class="text-sm text-gray-500 mb-2">{{ description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Boolean - check for widget first #}
|
{# Boolean - check for widget first #}
|
||||||
{% if field_type == 'boolean' %}
|
{% if field_type == 'boolean' %}
|
||||||
{% set bool_widget = prop.get('x-widget') or prop.get('x_widget') %}
|
{% set bool_widget = prop.get('x-widget') or prop.get('x_widget') %}
|
||||||
@@ -446,16 +450,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||||
<input type="hidden" name="{{ full_key }}.{{ item_index }}.enabled" value="{{ 'true' if item.get('enabled', true) else 'false' }}">
|
<input type="hidden" name="{{ full_key }}.{{ item_index }}.enabled" value="false">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="{{ full_key }}.{{ item_index }}.enabled"
|
name="{{ full_key }}.{{ item_index }}.enabled"
|
||||||
{% if item.get('enabled', true) %}checked{% endif %}
|
{% if item.get('enabled', true) %}checked{% endif %}
|
||||||
value="true"
|
value="true"
|
||||||
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
|
|
||||||
class="h-4 w-4 text-blue-600">
|
class="h-4 w-4 text-blue-600">
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="removeCustomFeedRow(this)"
|
onclick="removeCustomFeedRow(this)"
|
||||||
class="text-red-600 hover:text-red-800 px-2 py-1">
|
class="text-red-600 hover:text-red-800 px-2 py-1">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
@@ -546,18 +549,11 @@
|
|||||||
{% else %}{% set td_min_w = '110px' %}{% endif %}
|
{% else %}{% set td_min_w = '110px' %}{% endif %}
|
||||||
<td class="px-3 py-3 whitespace-nowrap" style="min-width:{{ td_min_w }};vertical-align:middle">
|
<td class="px-3 py-3 whitespace-nowrap" style="min-width:{{ td_min_w }};vertical-align:middle">
|
||||||
{% if col_type == 'boolean' %}
|
{% if col_type == 'boolean' %}
|
||||||
{# Hidden sentinel ensures unchecked boxes still submit "false" (browsers
|
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="false">
|
||||||
omit unchecked checkboxes entirely). It shares the checkbox's `name`,
|
|
||||||
so it must always mirror the checkbox's actual state — both here at
|
|
||||||
render time and on every toggle — or whichever of the two same-named
|
|
||||||
inputs the save request happens to prefer can silently revert this
|
|
||||||
field to false regardless of what's checked. #}
|
|
||||||
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="{{ 'true' if col_value else 'false' }}">
|
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||||
{% if col_value %}checked{% endif %}
|
{% if col_value %}checked{% endif %}
|
||||||
value="true"
|
value="true"
|
||||||
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
|
|
||||||
class="h-4 w-4 text-blue-600">
|
class="h-4 w-4 text-blue-600">
|
||||||
{% elif col_type == 'integer' or col_type == 'number' %}
|
{% elif col_type == 'integer' or col_type == 'number' %}
|
||||||
<input type="number"
|
<input type="number"
|
||||||
@@ -999,7 +995,6 @@
|
|||||||
{# Configuration Form Panel #}
|
{# Configuration Form Panel #}
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-3">Configuration</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-3">Configuration</h3>
|
||||||
{{ ui.settings_filter("Filter this plugin's settings…") }}
|
|
||||||
<div class="space-y-4 max-h-96 overflow-y-auto pr-2">
|
<div class="space-y-4 max-h-96 overflow-y-auto pr-2">
|
||||||
{% if schema and schema.properties %}
|
{% if schema and schema.properties %}
|
||||||
{# Use property order if defined, otherwise use natural order #}
|
{# Use property order if defined, otherwise use natural order #}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
{% import 'v3/partials/_macros.html' as ui %}
|
|
||||||
<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">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Schedule Settings</h2>
|
<h2 class="text-lg font-semibold text-gray-900">Schedule Settings</h2>
|
||||||
<p class="mt-1 text-sm text-gray-600">Configure when the LED matrix display should be active. You can set global hours or customize times for each day of the week.</p>
|
<p class="mt-1 text-sm text-gray-600">Configure when the LED matrix display should be active. You can set global hours or customize times for each day of the week.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ ui.settings_filter() }}
|
|
||||||
|
|
||||||
<form id="schedule_form"
|
<form id="schedule_form"
|
||||||
hx-post="/api/v3/config/schedule"
|
hx-post="/api/v3/config/schedule"
|
||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
@@ -45,9 +42,9 @@
|
|||||||
class="space-y-6">
|
class="space-y-6">
|
||||||
|
|
||||||
<!-- Dim Brightness Level -->
|
<!-- Dim Brightness Level -->
|
||||||
<div class="form-group bg-gray-50 rounded-lg p-4 mb-4" id="setting-schedule-dim_brightness" data-setting-key="dim_schedule.dim_brightness">
|
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||||
<label for="dim_brightness" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="dim_brightness" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Dim Brightness Level{{ ui.help_tip('Brightness the display drops to during dim hours (0–100%).\nApplies only while the display is on. Your normal brightness is currently ' ~ normal_brightness ~ '%.', 'Dim Brightness Level') }}
|
Dim Brightness Level
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<input type="range"
|
<input type="range"
|
||||||
@@ -62,6 +59,7 @@
|
|||||||
{{ dim_schedule_config.dim_brightness | default(30) }}%
|
{{ dim_schedule_config.dim_brightness | default(30) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Current normal brightness: {{ normal_brightness }}%</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dim Schedule Picker Widget Container -->
|
<!-- Dim Schedule Picker Widget Container -->
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -130,57 +112,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Power Supply Diagnostics -->
|
|
||||||
<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">Power Supply</h2>
|
|
||||||
<p class="mt-1 text-sm text-gray-600">Raspberry Pi under-voltage/throttling status (via <code class="bg-gray-100 px-1 rounded">vcgencmd get_throttled</code>). A marginal power supply is a common cause of visible flicker or dimming on LED panels.</p>
|
|
||||||
</div>
|
|
||||||
<div id="power-info-panel" class="text-sm">
|
|
||||||
<div class="animate-pulse text-gray-400">Waiting for live stats…</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 +146,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>
|
||||||
@@ -392,14 +229,7 @@
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({action})
|
body: JSON.stringify({action})
|
||||||
})
|
})
|
||||||
.then(r => {
|
.then(r => r.json())
|
||||||
if (!r.ok) {
|
|
||||||
return r.json()
|
|
||||||
.then(d => Promise.reject(new Error(d.message || `HTTP ${r.status}`)))
|
|
||||||
.catch(() => Promise.reject(new Error(`HTTP ${r.status}`)));
|
|
||||||
}
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const ok = data.status === 'success';
|
const ok = data.status === 'success';
|
||||||
showResult(
|
showResult(
|
||||||
@@ -444,7 +274,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const dirtyBadge = d.dirty
|
const dirtyBadge = d.dirty
|
||||||
? '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">dirty</span>'
|
? '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">dirty</span>'
|
||||||
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">clean</span>';
|
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">clean</span>';
|
||||||
@@ -479,360 +308,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── power supply diagnostics panel ────────────────────────────────────────
|
|
||||||
// Reuses the same SSE stream (window.statsSource, set up in base.html)
|
|
||||||
// that already drives the header badge/banner and Overview card, instead
|
|
||||||
// of polling a separate endpoint.
|
|
||||||
|
|
||||||
const FLAG_LABELS = [
|
|
||||||
['under_voltage_now', 'Under-voltage (right now)'],
|
|
||||||
['throttled_now', 'Throttled (right now)'],
|
|
||||||
['freq_capped_now', 'ARM frequency capped (right now)'],
|
|
||||||
['soft_temp_limit_now', 'Soft temperature limit active (right now)'],
|
|
||||||
['under_voltage_occurred', 'Under-voltage occurred (since boot)'],
|
|
||||||
['throttled_occurred', 'Throttled occurred (since boot)'],
|
|
||||||
['freq_capped_occurred', 'ARM frequency capped occurred (since boot)'],
|
|
||||||
['soft_temp_limit_occurred', 'Soft temperature limit occurred (since boot)'],
|
|
||||||
];
|
|
||||||
|
|
||||||
function renderPowerInfo(power) {
|
|
||||||
const panel = document.getElementById('power-info-panel');
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
if (!power) {
|
|
||||||
panel.innerHTML = '<span class="text-gray-500">Not available on this platform (no <code class="bg-gray-100 px-1 rounded">vcgencmd</code> found — this isn\'t a Raspberry Pi, or it\'s not on PATH).</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeNow = power.under_voltage_now || power.throttled_now ||
|
|
||||||
power.freq_capped_now || power.soft_temp_limit_now;
|
|
||||||
const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred ||
|
|
||||||
power.freq_capped_occurred || power.soft_temp_limit_occurred;
|
|
||||||
const summaryColor = activeNow ? 'red' : (occurredEarlier ? 'yellow' : 'green');
|
|
||||||
// _activePowerConditionLabels is defined in base.html's inline script;
|
|
||||||
// both are classic (non-module) scripts sharing the global scope.
|
|
||||||
const activeLabels = (typeof _activePowerConditionLabels === 'function')
|
|
||||||
? _activePowerConditionLabels(power) : [];
|
|
||||||
const summaryText = activeNow
|
|
||||||
? `Actively ${activeLabels.length ? activeLabels.join('/') : 'under-voltage/throttled'} right now`
|
|
||||||
: (occurredEarlier ? 'OK right now (but occurred earlier this boot)' : 'OK — no issues detected');
|
|
||||||
|
|
||||||
let html = `
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-${summaryColor}-100 text-${summaryColor}-800">
|
|
||||||
<i class="fas fa-bolt mr-1"></i>${escHtml(summaryText)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-1">`;
|
|
||||||
for (const [key, label] of FLAG_LABELS) {
|
|
||||||
const set = !!power[key];
|
|
||||||
html += `<li class="flex items-center gap-2">
|
|
||||||
<i class="fas ${set ? 'fa-exclamation-circle text-red-500' : 'fa-check text-green-500'} w-4"></i>
|
|
||||||
<span class="text-gray-700">${escHtml(label)}</span>
|
|
||||||
</li>`;
|
|
||||||
}
|
|
||||||
html += `</ul>`;
|
|
||||||
|
|
||||||
if (activeNow || occurredEarlier) {
|
|
||||||
html += `<p class="mt-3 text-xs text-gray-500">Consider a higher-amperage 5V supply wired directly to the panel(s), or check for a loose power connector. See the README's Power Supply section for sizing guidance.</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.statsSource) {
|
|
||||||
window.statsSource.addEventListener('message', function(event) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data && 'power' in data) renderPowerInfo(data.power);
|
|
||||||
} catch (e) { /* ignore malformed frames */ }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── system diagnostics panel ──────────────────────────────────────────────
|
|
||||||
// Reads the existing /api/v3/system/status JSON endpoint (10s cached) for
|
|
||||||
// richer metrics (disk, uptime, memory MB) than the SSE stats stream carries.
|
|
||||||
|
|
||||||
function diagTile(icon, iconColor, label, value, sub) {
|
|
||||||
return `
|
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0"><i class="fas ${icon} ${iconColor} text-xl"></i></div>
|
|
||||||
<div class="ml-3 w-0 flex-1">
|
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">${escHtml(label)}</dt>
|
|
||||||
<dd class="text-lg font-medium text-gray-900">${escHtml(value)}</dd>
|
|
||||||
${sub ? `<dd class="text-xs text-gray-400 mt-0.5">${escHtml(sub)}</dd>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.loadSystemDiagnostics = function() {
|
|
||||||
const panel = document.getElementById('diag-panel');
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
fetch('/api/v3/system/status')
|
|
||||||
.then(r => {
|
|
||||||
if (!r.ok) return r.json()
|
|
||||||
.then(d => Promise.reject(d.message || `HTTP ${r.status}`))
|
|
||||||
.catch(() => Promise.reject(`HTTP ${r.status}`));
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(res => {
|
|
||||||
const d = (res && res.data) || {};
|
|
||||||
const mUsedGb = d.memory_used_mb != null ? (d.memory_used_mb / 1024).toFixed(1) : null;
|
|
||||||
const mTotGb = d.memory_total_mb != null ? (d.memory_total_mb / 1024).toFixed(1) : null;
|
|
||||||
const temp = d.cpu_temp != null ? d.cpu_temp + '°C' : 'N/A';
|
|
||||||
panel.innerHTML =
|
|
||||||
diagTile('fa-microchip', 'text-blue-600', 'CPU Usage',
|
|
||||||
(d.cpu_percent != null ? d.cpu_percent : '--') + '%', null) +
|
|
||||||
diagTile('fa-memory', 'text-green-600', 'Memory',
|
|
||||||
(d.memory_used_percent != null ? d.memory_used_percent : '--') + '%',
|
|
||||||
(mUsedGb && mTotGb) ? `${mUsedGb} / ${mTotGb} GB` : null) +
|
|
||||||
diagTile('fa-thermometer-half', 'text-red-600', 'CPU Temp', temp, null) +
|
|
||||||
diagTile('fa-hdd', 'text-indigo-600', 'Disk',
|
|
||||||
(d.disk_used_percent != null ? d.disk_used_percent : '--') + '%',
|
|
||||||
(d.disk_used_gb != null && d.disk_total_gb != null) ? `${d.disk_used_gb} / ${d.disk_total_gb} GB` : null) +
|
|
||||||
diagTile('fa-clock', 'text-purple-600', 'Uptime', d.uptime || '--', null) +
|
|
||||||
diagTile('fa-desktop', d.service_active ? 'text-green-600' : 'text-gray-400',
|
|
||||||
'Display Service', d.service_active ? 'Active' : 'Inactive', null);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
panel.innerHTML = `<div class="col-span-full text-sm text-red-600">Diagnostics unavailable: ${escHtml(String(err))}</div>`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── system power (reboot / shutdown) ──────────────────────────────────────
|
|
||||||
// Like toolsAction, but a dropped connection is the expected, successful
|
|
||||||
// outcome (the Pi is going down), so it is reported as info, not an error.
|
|
||||||
|
|
||||||
window.powerAction = function(action, btnId, resultId, offlineMsg) {
|
|
||||||
setBusy(btnId, true);
|
|
||||||
const el = document.getElementById(resultId);
|
|
||||||
if (el) el.classList.add('hidden');
|
|
||||||
|
|
||||||
fetch('/api/v3/system/action', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({action})
|
|
||||||
})
|
|
||||||
.then(r => r.json().catch(() => ({status: 'success'})))
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'error') {
|
|
||||||
showResult(resultId, false, data.message || 'Command failed');
|
|
||||||
} else {
|
|
||||||
showResult(resultId, true, offlineMsg);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Connection dropped mid-request — expected when the Pi reboots/powers off.
|
|
||||||
showResult(resultId, true, offlineMsg);
|
|
||||||
})
|
|
||||||
.finally(() => setBusy(btnId, false));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.showPowerConfirm = function(kind) {
|
|
||||||
document.getElementById(kind + '-confirm-row').classList.remove('hidden');
|
|
||||||
document.getElementById('btn-' + kind).classList.add('hidden');
|
|
||||||
};
|
|
||||||
window.hidePowerConfirm = function(kind) {
|
|
||||||
document.getElementById(kind + '-confirm-row').classList.add('hidden');
|
|
||||||
document.getElementById('btn-' + kind).classList.remove('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── WiFi radio toggle ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function renderWifiRadio(state) {
|
|
||||||
const toggle = document.getElementById('wifi-radio-toggle');
|
|
||||||
const knob = document.getElementById('wifi-radio-knob');
|
|
||||||
const note = document.getElementById('wifi-radio-note');
|
|
||||||
if (!toggle || !knob || !note) return;
|
|
||||||
|
|
||||||
const setKnob = (on) => {
|
|
||||||
if (on) {
|
|
||||||
knob.classList.remove('translate-x-0'); knob.classList.add('translate-x-5');
|
|
||||||
toggle.classList.remove('bg-gray-200'); toggle.classList.add('bg-blue-600');
|
|
||||||
} else {
|
|
||||||
knob.classList.remove('translate-x-5'); knob.classList.add('translate-x-0');
|
|
||||||
toggle.classList.remove('bg-blue-600'); toggle.classList.add('bg-gray-200');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!state || state.available === false) {
|
|
||||||
toggle.disabled = true;
|
|
||||||
toggle.classList.add('opacity-60');
|
|
||||||
toggle.setAttribute('aria-checked', 'false');
|
|
||||||
setKnob(false);
|
|
||||||
note.textContent = 'WiFi radio control is not available on this system (nmcli not found).';
|
|
||||||
note.className = 'text-xs text-gray-500 mt-0.5';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const on = state.enabled === true;
|
|
||||||
toggle.disabled = false;
|
|
||||||
toggle.classList.remove('opacity-60');
|
|
||||||
toggle.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
||||||
setKnob(on);
|
|
||||||
const eth = state.ethernet_connected
|
|
||||||
? 'Wired connection detected — safe to turn WiFi off.'
|
|
||||||
: 'No wired connection — turning WiFi off will disconnect this page.';
|
|
||||||
note.textContent = `Radio is ${on ? 'on' : 'off'}. ${eth}`;
|
|
||||||
note.className = 'text-xs mt-0.5 ' + (state.ethernet_connected ? 'text-gray-500' : 'text-amber-600');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.loadWifiRadio = function() {
|
|
||||||
fetch('/api/v3/wifi/radio')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(res => renderWifiRadio(res && res.data))
|
|
||||||
.catch(() => renderWifiRadio(null));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.onWifiRadioToggleClick = function() {
|
|
||||||
const toggle = document.getElementById('wifi-radio-toggle');
|
|
||||||
if (!toggle || toggle.disabled) return;
|
|
||||||
const currentlyOn = toggle.getAttribute('aria-checked') === 'true';
|
|
||||||
setWifiRadio(!currentlyOn, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
function setWifiRadio(enabled, force) {
|
|
||||||
const toggle = document.getElementById('wifi-radio-toggle');
|
|
||||||
const resultEl = document.getElementById('result-wifi-radio');
|
|
||||||
if (toggle) toggle.disabled = true;
|
|
||||||
if (resultEl) resultEl.classList.add('hidden');
|
|
||||||
hideWifiForceRow();
|
|
||||||
|
|
||||||
fetch('/api/v3/wifi/radio', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({enabled, force})
|
|
||||||
})
|
|
||||||
.then(r => r.json().then(d => ({ok: r.ok, d})))
|
|
||||||
.then(({ok, d}) => {
|
|
||||||
if (ok && d.status === 'success') {
|
|
||||||
if (d.data) renderWifiRadio(d.data); else window.loadWifiRadio();
|
|
||||||
showResult('result-wifi-radio', true, d.message || 'Done');
|
|
||||||
} else if (!enabled && !force && d.reason === 'no_ethernet') {
|
|
||||||
// Disable refused for safety (no wired fallback) — offer the force path.
|
|
||||||
showWifiForceRow();
|
|
||||||
window.loadWifiRadio();
|
|
||||||
} else {
|
|
||||||
showResult('result-wifi-radio', false, d.message || 'Failed to change WiFi radio.');
|
|
||||||
window.loadWifiRadio();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
showResult('result-wifi-radio', false, 'Request failed: ' + err.message);
|
|
||||||
window.loadWifiRadio();
|
|
||||||
})
|
|
||||||
.finally(() => { if (toggle) toggle.disabled = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
window.forceDisableWifiRadio = function() {
|
|
||||||
hideWifiForceRow();
|
|
||||||
setWifiRadio(false, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
function showWifiForceRow() {
|
|
||||||
const row = document.getElementById('wifi-radio-force-row');
|
|
||||||
if (row) { row.classList.remove('hidden'); row.classList.add('flex'); }
|
|
||||||
}
|
|
||||||
window.hideWifiForceRow = function() {
|
|
||||||
const row = document.getElementById('wifi-radio-force-row');
|
|
||||||
if (row) { row.classList.add('hidden'); row.classList.remove('flex'); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── plugin health panel ──────────────────────────────────────────────────
|
|
||||||
function phEscape(s) {
|
|
||||||
return String(s).replace(/[&<>"']/g, function (c) {
|
|
||||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function phFmtSecs(v) {
|
|
||||||
if (typeof v !== 'number' || !isFinite(v)) return '—';
|
|
||||||
return v.toFixed(3) + 's';
|
|
||||||
}
|
|
||||||
function phStatus(h) {
|
|
||||||
if (!h) return { label: 'Unknown', cls: 'warning' };
|
|
||||||
if (h.circuit_state === 'open') return { label: 'Circuit open', cls: 'error' };
|
|
||||||
if (h.circuit_state === 'half_open') return { label: 'Recovering', cls: 'warning' };
|
|
||||||
if (h.degraded) return { label: 'Degraded', cls: 'warning' };
|
|
||||||
if (h.is_healthy) return { label: 'Healthy', cls: 'success' };
|
|
||||||
return { label: 'Unknown', cls: 'warning' };
|
|
||||||
}
|
|
||||||
async function refreshPluginHealth(force) {
|
|
||||||
const tbody = document.getElementById('plugin-health-tbody');
|
|
||||||
const msg = document.getElementById('plugin-health-message');
|
|
||||||
if (!tbody || !window.PluginAPI) return;
|
|
||||||
try {
|
|
||||||
if (force && PluginAPI.clearCache) PluginAPI.clearCache();
|
|
||||||
const results = await Promise.all([
|
|
||||||
PluginAPI.getPluginHealth(),
|
|
||||||
PluginAPI.getPluginMetrics()
|
|
||||||
]);
|
|
||||||
const health = results[0] || {};
|
|
||||||
const metrics = results[1] || {};
|
|
||||||
const ids = Array.from(new Set(Object.keys(health).concat(Object.keys(metrics)))).sort();
|
|
||||||
if (!ids.length) {
|
|
||||||
if (msg) {
|
|
||||||
msg.textContent = 'No plugin health data yet — it appears once the display service has run plugins.';
|
|
||||||
msg.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">No data</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg) msg.classList.add('hidden');
|
|
||||||
let rows = '';
|
|
||||||
ids.forEach(function (id) {
|
|
||||||
const h = health[id] || {};
|
|
||||||
const m = metrics[id] || {};
|
|
||||||
const st = phStatus(h);
|
|
||||||
const lastErr = h.degraded_reason || h.last_error || '';
|
|
||||||
const calls = (typeof m.call_count === 'number') ? m.call_count : '—';
|
|
||||||
const errCell = lastErr
|
|
||||||
? '<span title="' + phEscape(lastErr) + '">' + phEscape(lastErr) + '</span>'
|
|
||||||
: '<span class="text-gray-400">—</span>';
|
|
||||||
rows += '<tr>' +
|
|
||||||
'<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">' + phEscape(id) + '</td>' +
|
|
||||||
'<td class="px-4 py-3 whitespace-nowrap"><span class="status-indicator ' + st.cls + '">' + st.label + '</span></td>' +
|
|
||||||
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + phFmtSecs(m.avg_execution_time) + '</td>' +
|
|
||||||
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + phFmtSecs(m.max_execution_time) + '</td>' +
|
|
||||||
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + calls + '</td>' +
|
|
||||||
'<td class="px-4 py-3 text-sm text-red-600 max-w-xs truncate">' + errCell + '</td>' +
|
|
||||||
'</tr>';
|
|
||||||
});
|
|
||||||
tbody.innerHTML = rows;
|
|
||||||
} catch (e) {
|
|
||||||
const emsg = (e && e.message) ? e.message : String(e);
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="px-4 py-6 text-center text-red-500">Failed to load plugin health: ' + phEscape(emsg) + '</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.refreshPluginHealth = refreshPluginHealth;
|
|
||||||
|
|
||||||
// Load on first render; HTMX will have already swapped us in by this point.
|
// 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>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
{% import 'v3/partials/_macros.html' as ui %}
|
|
||||||
<div class="bg-white rounded-lg shadow p-6" x-data="wifiSetup()" x-init="init(); loadStatus()">
|
<div class="bg-white rounded-lg shadow p-6" x-data="wifiSetup()" x-init="init(); loadStatus()">
|
||||||
<!-- Captive Portal Banner (shown when AP mode is active) -->
|
<!-- Captive Portal Banner (shown when AP mode is active) -->
|
||||||
<div x-show="status.ap_mode_active"
|
<div x-show="status.ap_mode_active"
|
||||||
@@ -24,8 +23,6 @@
|
|||||||
<p class="mt-1 text-sm text-gray-600">Configure WiFi connection for your Raspberry Pi. Access point mode will automatically activate when no WiFi connection is detected.</p>
|
<p class="mt-1 text-sm text-gray-600">Configure WiFi connection for your Raspberry Pi. Access point mode will automatically activate when no WiFi connection is detected.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ ui.settings_filter('Filter WiFi settings…') }}
|
|
||||||
|
|
||||||
<!-- Current WiFi Status -->
|
<!-- Current WiFi Status -->
|
||||||
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
|
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||||
<h3 class="text-sm font-medium text-gray-900 mb-2">Current Status</h3>
|
<h3 class="text-sm font-medium text-gray-900 mb-2">Current Status</h3>
|
||||||
@@ -76,9 +73,9 @@
|
|||||||
<h3 class="text-sm font-medium text-gray-900 mb-4">Connect to WiFi Network</h3>
|
<h3 class="text-sm font-medium text-gray-900 mb-4">Connect to WiFi Network</h3>
|
||||||
|
|
||||||
<!-- Network Selection -->
|
<!-- Network Selection -->
|
||||||
<div class="form-group mb-4" id="setting-wifi-ssid" data-setting-key="wifi.ssid">
|
<div class="form-group mb-4">
|
||||||
<label for="wifi-ssid" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="wifi-ssid" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Step 1: Select Network{{ ui.help_tip('Choose the WiFi network to join.\nClick Scan to list nearby networks, or type the name manually below if it is hidden.', 'Select Network') }}
|
Step 1: Select Network
|
||||||
</label>
|
</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<select id="wifi-ssid"
|
<select id="wifi-ssid"
|
||||||
@@ -101,6 +98,7 @@
|
|||||||
<span class="ml-2">Scan</span>
|
<span class="ml-2">Scan</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Scan for available networks or manually enter SSID below.</p>
|
||||||
<!-- Show selected network -->
|
<!-- Show selected network -->
|
||||||
<div x-show="selectedSSID" class="mt-2 p-2 bg-blue-50 border border-blue-200 rounded text-sm" x-cloak>
|
<div x-show="selectedSSID" class="mt-2 p-2 bg-blue-50 border border-blue-200 rounded text-sm" x-cloak>
|
||||||
<i class="fas fa-check-circle text-blue-600 mr-2"></i>
|
<i class="fas fa-check-circle text-blue-600 mr-2"></i>
|
||||||
@@ -109,9 +107,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manual SSID Entry -->
|
<!-- Manual SSID Entry -->
|
||||||
<div class="form-group mb-4" id="setting-wifi-manual_ssid" data-setting-key="wifi.manual_ssid">
|
<div class="form-group mb-4">
|
||||||
<label for="manual-ssid" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="manual-ssid" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Or Enter SSID Manually{{ ui.help_tip('Type a network name by hand when it is hidden or not shown in the scan results.\nExact spelling and capitalization matter.', 'Enter SSID Manually') }}
|
Or Enter SSID Manually
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="manual-ssid"
|
id="manual-ssid"
|
||||||
@@ -122,15 +120,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password -->
|
<!-- Password -->
|
||||||
<div class="form-group mb-4" id="setting-wifi-password" data-setting-key="wifi.password">
|
<div class="form-group mb-4">
|
||||||
<label for="wifi-password" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="wifi-password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Step 2: Enter Password{{ ui.help_tip('Password for the selected network.\nLeave empty if the network is open (no password required).', 'WiFi Password') }}
|
Step 2: Enter Password
|
||||||
</label>
|
</label>
|
||||||
<input type="password"
|
<input type="password"
|
||||||
id="wifi-password"
|
id="wifi-password"
|
||||||
x-model="password"
|
x-model="password"
|
||||||
placeholder="Enter password (leave empty for open networks)"
|
placeholder="Enter password (leave empty for open networks)"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Enter the WiFi password. Leave empty if the network is open (no password required).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connect Button -->
|
<!-- Connect Button -->
|
||||||
@@ -152,10 +154,14 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Auto-Enable Toggle -->
|
<!-- Auto-Enable Toggle -->
|
||||||
<div class="form-group mb-4 p-4 bg-gray-50 rounded-lg border border-gray-200" id="setting-wifi-auto_enable_ap_mode" data-setting-key="wifi.auto_enable_ap_mode">
|
<div class="mb-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="text-sm font-medium text-gray-900 block mb-1">Auto-Enable AP Mode{{ ui.help_tip('Automatically start access-point mode when both WiFi and Ethernet are disconnected, so you can always reach the device to reconfigure it.\nDefault: on. When off, AP mode must be enabled manually.', 'Auto-Enable AP Mode') }}</label>
|
<label class="text-sm font-medium text-gray-900 block mb-1">Auto-Enable AP Mode</label>
|
||||||
|
<p class="text-xs text-gray-600">
|
||||||
|
When enabled, AP mode will automatically activate when both WiFi and Ethernet are disconnected.
|
||||||
|
When disabled, AP mode must be manually enabled.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
|||||||