mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-07 19:58:08 +00:00
Compare commits
3
Commits
fefc2d44a2
...
8e5f66501a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e5f66501a | ||
|
|
639e1c3a93 | ||
|
|
6096a22c3d |
@@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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 *)'
|
||||||
|
|
||||||
+13
-7
@@ -227,8 +227,8 @@ wait_for_apt_lock() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
apt_update() { wait_for_apt_lock; retry apt update; }
|
apt_update() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 update; }
|
||||||
apt_install() { wait_for_apt_lock; retry apt install -y "$@"; }
|
apt_install() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 install -y "$@"; }
|
||||||
apt_remove() { apt-get remove -y "$@" || true; }
|
apt_remove() { apt-get remove -y "$@" || true; }
|
||||||
|
|
||||||
check_network() {
|
check_network() {
|
||||||
@@ -857,24 +857,30 @@ 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 git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
retry _clone_rpi_rgb
|
||||||
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 git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
retry _clone_rpi_rgb
|
||||||
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)
|
||||||
@@ -885,7 +891,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 git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
retry _clone_rpi_rgb
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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,
|
||||||
@@ -17,7 +18,7 @@ from pathlib import Path
|
|||||||
ERROR_TAIL_LINES = 15
|
ERROR_TAIL_LINES = 15
|
||||||
|
|
||||||
|
|
||||||
def _run(cmd):
|
def _run(cmd: List[str]) -> Tuple[bool, str]:
|
||||||
"""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
|
||||||
@@ -37,7 +38,7 @@ def _run(cmd):
|
|||||||
return result.returncode == 0, '\n'.join(tail)
|
return result.returncode == 0, '\n'.join(tail)
|
||||||
|
|
||||||
|
|
||||||
def install_via_apt(package_name):
|
def install_via_apt(package_name: str) -> Tuple[bool, str]:
|
||||||
"""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 = {
|
||||||
@@ -59,7 +60,7 @@ def install_via_apt(package_name):
|
|||||||
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', 'install', '-y', apt_package])
|
success, output = _run(['sudo', 'apt-get', '-o', 'DPkg::Lock::Timeout=180', '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, ""
|
||||||
@@ -68,7 +69,7 @@ def install_via_apt(package_name):
|
|||||||
return False, output
|
return False, output
|
||||||
|
|
||||||
|
|
||||||
def install_via_pip(package_name):
|
def install_via_pip(package_name: str) -> Tuple[bool, str]:
|
||||||
"""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
|
||||||
@@ -105,7 +106,7 @@ IMPORT_NAME_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def check_package_installed(package_name):
|
def check_package_installed(package_name: str) -> bool:
|
||||||
"""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
|
||||||
@@ -119,7 +120,7 @@ def check_package_installed(package_name):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def print_failure_summary(failed_packages, failure_details):
|
def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None:
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
|
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
@@ -208,7 +209,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', '--ignore-installed', str(rgbmatrix_path)])
|
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
|
||||||
if ok:
|
if ok:
|
||||||
print("rgbmatrix module installed successfully")
|
print("rgbmatrix module installed successfully")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -28,6 +29,32 @@ from src.error_aggregator import get_error_aggregator
|
|||||||
|
|
||||||
_SUDO = shutil.which('sudo')
|
_SUDO = shutil.which('sudo')
|
||||||
_JOURNALCTL = shutil.which('journalctl')
|
_JOURNALCTL = shutil.which('journalctl')
|
||||||
|
_GIT = shutil.which('git')
|
||||||
|
|
||||||
|
# Cap subprocess output returned to the browser — pip can produce MBs on build failures.
|
||||||
|
_MAX_OUTPUT_BYTES = 51_200 # 50 KB
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_output(stdout: str, stderr: str) -> str:
|
||||||
|
"""Combine stdout+stderr and truncate to _MAX_OUTPUT_BYTES (keeping the tail)."""
|
||||||
|
combined = (stdout + stderr).strip()
|
||||||
|
if len(combined) > _MAX_OUTPUT_BYTES:
|
||||||
|
combined = '[...output truncated...]\n' + combined[-_MAX_OUTPUT_BYTES:]
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_git_remote_url(url: str) -> str:
|
||||||
|
"""Strip embedded username/password from an HTTPS remote URL before returning it to the UI."""
|
||||||
|
try:
|
||||||
|
p = urlparse(url)
|
||||||
|
if p.scheme in ('http', 'https') and (p.username or p.password):
|
||||||
|
netloc = p.hostname or ''
|
||||||
|
if p.port:
|
||||||
|
netloc += f':{p.port}'
|
||||||
|
return urlunparse(p._replace(netloc=netloc))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return url
|
||||||
|
|
||||||
# Will be initialized when blueprint is registered
|
# Will be initialized when blueprint is registered
|
||||||
config_manager = None
|
config_manager = None
|
||||||
@@ -705,7 +732,8 @@ def save_main_config():
|
|||||||
display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping',
|
display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping',
|
||||||
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
|
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
|
||||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
|
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
|
||||||
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type']
|
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type',
|
||||||
|
'row_address_type']
|
||||||
|
|
||||||
if any(k in data for k in display_fields):
|
if any(k in data for k in display_fields):
|
||||||
if 'display' not in current_config:
|
if 'display' not in current_config:
|
||||||
@@ -736,14 +764,23 @@ def save_main_config():
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
|
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
|
||||||
|
|
||||||
|
# Validate row_address_type
|
||||||
|
if 'row_address_type' in data:
|
||||||
|
try:
|
||||||
|
rat_val = int(data['row_address_type'])
|
||||||
|
if rat_val < 0 or rat_val > 4:
|
||||||
|
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
|
||||||
|
|
||||||
# Handle hardware settings
|
# Handle hardware settings
|
||||||
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
||||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||||
'led_rgb_sequence', 'multiplexing', 'panel_type']:
|
'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type']:
|
||||||
if field in data:
|
if field in data:
|
||||||
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
|
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
|
||||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||||
'multiplexing']:
|
'multiplexing', 'row_address_type']:
|
||||||
current_config['display']['hardware'][field] = int(data[field])
|
current_config['display']['hardware'][field] = int(data[field])
|
||||||
else:
|
else:
|
||||||
current_config['display']['hardware'][field] = data[field]
|
current_config['display']['hardware'][field] = data[field]
|
||||||
@@ -792,6 +829,19 @@ def save_main_config():
|
|||||||
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
|
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
|
||||||
if not (2 <= copies <= 8):
|
if not (2 <= copies <= 8):
|
||||||
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
|
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
|
||||||
|
# Validate divisibility against the relevant hardware dimension.
|
||||||
|
# Use axis from this request if provided, else from stored config.
|
||||||
|
hw = current_config.get('display', {}).get('hardware', {})
|
||||||
|
effective_axis = (data.get('double_sided_axis')
|
||||||
|
or current_config.get('display', {}).get('double_sided', {}).get('axis', 'horizontal'))
|
||||||
|
if effective_axis == 'horizontal':
|
||||||
|
chain_length = int(hw.get('chain_length', 2) or 2)
|
||||||
|
if chain_length % copies != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"}), 400
|
||||||
|
elif effective_axis == 'vertical':
|
||||||
|
parallel = int(hw.get('parallel', 1) or 1)
|
||||||
|
if parallel % copies != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"}), 400
|
||||||
ds_config['copies'] = copies
|
ds_config['copies'] = copies
|
||||||
|
|
||||||
if 'double_sided_axis' in data:
|
if 'double_sided_axis' in data:
|
||||||
@@ -1045,6 +1095,8 @@ def save_main_config():
|
|||||||
continue
|
continue
|
||||||
if key in vegas_fields:
|
if key in vegas_fields:
|
||||||
continue
|
continue
|
||||||
|
if key in double_sided_fields:
|
||||||
|
continue
|
||||||
# For any remaining keys (including plugin keys), use deep merge to preserve existing settings
|
# For any remaining keys (including plugin keys), use deep merge to preserve existing settings
|
||||||
if key in current_config and isinstance(current_config[key], dict) and isinstance(data[key], dict):
|
if key in current_config and isinstance(current_config[key], dict) and isinstance(data[key], dict):
|
||||||
# Deep merge to preserve existing settings
|
# Deep merge to preserve existing settings
|
||||||
@@ -1615,6 +1667,87 @@ def execute_system_action():
|
|||||||
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action == 'install_base_requirements':
|
||||||
|
req_file = PROJECT_ROOT / 'requirements.txt'
|
||||||
|
if not req_file.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req_file)],
|
||||||
|
capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT)
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
|
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
|
||||||
|
'output': _truncate_output(result.stdout, result.stderr)
|
||||||
|
})
|
||||||
|
elif action == 'install_plugin_requirements':
|
||||||
|
active_pm = getattr(api_v3, 'plugin_manager', None)
|
||||||
|
if active_pm:
|
||||||
|
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 = []
|
||||||
|
if plugins_dir.exists():
|
||||||
|
for p in sorted(plugins_dir.iterdir()):
|
||||||
|
req = p / 'requirements.txt'
|
||||||
|
if p.is_dir() and req.exists():
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req)],
|
||||||
|
capture_output=True, text=True, timeout=60
|
||||||
|
)
|
||||||
|
results.append({
|
||||||
|
'plugin': p.name,
|
||||||
|
'ok': r.returncode == 0,
|
||||||
|
'output': _truncate_output(r.stdout, r.stderr)
|
||||||
|
})
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
results.append({'plugin': p.name, 'ok': False, 'output': 'pip install timed out'})
|
||||||
|
except OSError as exc:
|
||||||
|
results.append({'plugin': p.name, 'ok': False, 'output': exc.strerror or 'OS error'})
|
||||||
|
ok_count = sum(1 for r in results if r['ok'])
|
||||||
|
all_ok = all(r['ok'] for r in results) if results else True
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if all_ok else 'error',
|
||||||
|
'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found',
|
||||||
|
'details': results
|
||||||
|
})
|
||||||
|
elif action == 'force_git_reset':
|
||||||
|
if not _GIT:
|
||||||
|
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
|
||||||
|
project_dir = str(PROJECT_ROOT)
|
||||||
|
fetch = subprocess.run(
|
||||||
|
[_GIT, 'fetch', 'origin'],
|
||||||
|
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||||
|
)
|
||||||
|
if fetch.returncode != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()})
|
||||||
|
reset = subprocess.run(
|
||||||
|
[_GIT, 'reset', '--hard', 'origin/main'],
|
||||||
|
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if reset.returncode == 0 else 'error',
|
||||||
|
'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed',
|
||||||
|
'output': (reset.stdout + reset.stderr).strip()
|
||||||
|
})
|
||||||
|
elif action == 'clear_pycache':
|
||||||
|
cleared = 0
|
||||||
|
failed = 0
|
||||||
|
for d in PROJECT_ROOT.rglob('__pycache__'):
|
||||||
|
if d.is_dir():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(d)
|
||||||
|
cleared += 1
|
||||||
|
except OSError:
|
||||||
|
failed += 1
|
||||||
|
msg = f'Cleared {cleared} __pycache__ directories'
|
||||||
|
if failed:
|
||||||
|
msg += f' ({failed} could not be removed)'
|
||||||
|
return jsonify({'status': 'success', 'message': msg})
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
||||||
|
|
||||||
@@ -1637,6 +1770,35 @@ def execute_system_action():
|
|||||||
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
|
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
|
||||||
|
|
||||||
|
@api_v3.route('/system/git-info', methods=['GET'])
|
||||||
|
def get_git_info():
|
||||||
|
"""Return branch, dirty state, recent commits and remote URL for the Tools tab."""
|
||||||
|
if not _GIT:
|
||||||
|
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
|
||||||
|
d = str(PROJECT_ROOT)
|
||||||
|
try:
|
||||||
|
branch = subprocess.run([_GIT, 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||||
|
if branch.returncode != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': f'git branch failed: {branch.stderr.strip()}'}), 500
|
||||||
|
|
||||||
|
status = subprocess.run([_GIT, 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d)
|
||||||
|
if status.returncode != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': f'git status failed: {status.stderr.strip()}'}), 500
|
||||||
|
|
||||||
|
log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||||
|
remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||||
|
return jsonify({
|
||||||
|
'branch': branch.stdout.strip(),
|
||||||
|
'dirty': bool(status.stdout.strip()),
|
||||||
|
'status': status.stdout.strip(),
|
||||||
|
'recent_commits': log.stdout.strip() if log.returncode == 0 else '',
|
||||||
|
'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_git_info failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/hardware/status', methods=['GET'])
|
@api_v3.route('/hardware/status', methods=['GET'])
|
||||||
def get_hardware_status():
|
def get_hardware_status():
|
||||||
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
||||||
@@ -4463,6 +4625,49 @@ 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:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from flask import Blueprint, render_template, flash
|
from flask import Blueprint, render_template, flash
|
||||||
|
from jinja2 import TemplateNotFound
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -90,6 +91,8 @@ def load_partial(partial_name):
|
|||||||
return _load_cache_partial()
|
return _load_cache_partial()
|
||||||
elif partial_name == 'operation-history':
|
elif partial_name == 'operation-history':
|
||||||
return _load_operation_history_partial()
|
return _load_operation_history_partial()
|
||||||
|
elif partial_name == 'tools':
|
||||||
|
return _load_tools_partial()
|
||||||
else:
|
else:
|
||||||
return "Partial not found", 404
|
return "Partial not found", 404
|
||||||
|
|
||||||
@@ -448,6 +451,18 @@ def _load_operation_history_partial():
|
|||||||
return "Error loading partial", 500
|
return "Error loading partial", 500
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tools_partial():
|
||||||
|
"""Load tools/utilities partial."""
|
||||||
|
try:
|
||||||
|
return render_template('v3/partials/tools.html')
|
||||||
|
except TemplateNotFound:
|
||||||
|
logger.error("[Pages V3][Tools] Template not found: v3/partials/tools.html", exc_info=True)
|
||||||
|
return "[Pages V3][Tools] Template is missing.", 500
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("[Pages V3][Tools] I/O error loading tools partial: %s", exc, exc_info=True)
|
||||||
|
return "[Pages V3][Tools] Failed to load due to a file system error. Check logs.", 500
|
||||||
|
|
||||||
|
|
||||||
def _load_plugin_config_partial(plugin_id):
|
def _load_plugin_config_partial(plugin_id):
|
||||||
"""
|
"""
|
||||||
Load plugin configuration partial - server-side rendered form.
|
Load plugin configuration partial - server-side rendered form.
|
||||||
|
|||||||
@@ -54,15 +54,18 @@
|
|||||||
const logoIdInput = row.querySelector('input[name*=".logo.id"]');
|
const logoIdInput = row.querySelector('input[name*=".logo.id"]');
|
||||||
|
|
||||||
if (nameInput && urlInput) {
|
if (nameInput && urlInput) {
|
||||||
feeds.push({
|
const feedObj = {
|
||||||
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1009,6 +1009,11 @@
|
|||||||
class="nav-tab">
|
class="nav-tab">
|
||||||
<i class="fas fa-history"></i>Operation History
|
<i class="fas fa-history"></i>Operation History
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="activeTab = 'tools'"
|
||||||
|
:class="activeTab === 'tools' ? 'nav-tab-active' : ''"
|
||||||
|
class="nav-tab">
|
||||||
|
<i class="fas fa-tools"></i>Tools
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1290,6 +1295,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tools tab -->
|
||||||
|
<div x-show="activeTab === 'tools'" x-transition>
|
||||||
|
<div id="tools-content" hx-get="/v3/partials/tools" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
|
<div class="animate-pulse">
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
|
<div class="h-32 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Dynamic Plugin Tabs - HTMX Lazy Loading -->
|
<!-- Dynamic Plugin Tabs - HTMX Lazy Loading -->
|
||||||
<!--
|
<!--
|
||||||
Architecture: Server-side rendered plugin configuration forms
|
Architecture: Server-side rendered plugin configuration forms
|
||||||
@@ -1905,7 +1922,22 @@
|
|||||||
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
||||||
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
||||||
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
||||||
}
|
else if (tab === 'tools') {
|
||||||
|
fetch('/v3/partials/tools')
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then(html => {
|
||||||
|
contentEl.innerHTML = html;
|
||||||
|
contentEl.setAttribute('data-loaded', 'true');
|
||||||
|
if (window.Alpine) window.Alpine.initTree(contentEl);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to load tools content:', err);
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,18 @@
|
|||||||
</select>
|
</select>
|
||||||
<p class="mt-1 text-sm text-gray-600">Special panel chipset initialization (use Standard unless your panel requires it)</p>
|
<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">
|
||||||
|
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type</label>
|
||||||
|
<select id="row_address_type" name="row_address_type" class="form-control">
|
||||||
|
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option>
|
||||||
|
<option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
|
||||||
|
<option value="2" {% if main_config.display.hardware.get('row_address_type', 0)|int == 2 %}selected{% endif %}>2 - Row direct</option>
|
||||||
|
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
|
||||||
|
<option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Row addressing scheme — leave at Default (0) unless your panel requires a specific type</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
<div class="space-y-6" id="tools-root">
|
||||||
|
|
||||||
|
<!-- Git & Updates -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Git & Updates</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Inspect the current git state and pull or reset to the latest remote code.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Git status info -->
|
||||||
|
<div id="git-info-panel" class="mb-6 bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm">
|
||||||
|
<div class="animate-pulse text-gray-400">Loading git info…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Pull latest -->
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Pull latest (rebase)</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">Stashes any local changes, then runs <code class="bg-gray-100 px-1 rounded">git pull --rebase</code>. The stash is preserved but not re-applied.</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-git-pull" onclick="toolsAction('git_pull', 'btn-git-pull', 'result-git-pull')"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-download mr-2"></i>Pull Latest
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="result-git-pull" class="hidden"></div>
|
||||||
|
|
||||||
|
<!-- Force reset -->
|
||||||
|
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Force reset to <code class="bg-gray-100 px-1 rounded">origin/main</code></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">git fetch origin</code> then <code class="bg-gray-100 px-1 rounded">git reset --hard origin/main</code>. Discards all local changes.</p>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 flex flex-col items-end gap-2">
|
||||||
|
<button id="btn-force-reset-confirm" onclick="showForceResetConfirm()"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2"></i>Force Reset…
|
||||||
|
</button>
|
||||||
|
<div id="force-reset-confirm-row" class="hidden flex items-center gap-2">
|
||||||
|
<span class="text-xs text-red-700 font-medium">This discards all local changes. Sure?</span>
|
||||||
|
<button onclick="toolsAction('force_git_reset', 'btn-force-reset-confirm', 'result-force-reset'); hideForceResetConfirm()"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||||
|
Yes, reset
|
||||||
|
</button>
|
||||||
|
<button onclick="hideForceResetConfirm()"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="result-force-reset" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Python Dependencies -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Python Dependencies</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Re-run <code class="bg-gray-100 px-1 rounded">pip install</code> to fix missing or broken packages.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Base requirements -->
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Reinstall base requirements</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">Installs from <code class="bg-gray-100 px-1 rounded">requirements.txt</code> in the project root.</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-base-reqs" onclick="toolsAction('install_base_requirements', 'btn-base-reqs', 'result-base-reqs', true)"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-box mr-2"></i>Reinstall Base
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="result-base-reqs" class="hidden"></div>
|
||||||
|
|
||||||
|
<!-- Plugin requirements -->
|
||||||
|
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Reinstall plugin requirements</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">pip install</code> for every installed plugin that has a <code class="bg-gray-100 px-1 rounded">requirements.txt</code>.</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-plugin-reqs" onclick="toolsAction('install_plugin_requirements', 'btn-plugin-reqs', 'result-plugin-reqs', false, true)"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-puzzle-piece mr-2"></i>Reinstall Plugin Deps
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="result-plugin-reqs" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Maintenance -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Maintenance</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Housekeeping operations that don't affect config or plugins.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Clear Python cache</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">Deletes all <code class="bg-gray-100 px-1 rounded">__pycache__</code> directories in the project. Useful after switching branches or debugging import issues.</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-clear-pycache" onclick="toolsAction('clear_pycache', 'btn-clear-pycache', 'result-clear-pycache')"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-broom mr-2"></i>Clear Cache
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="result-clear-pycache" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Services</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Quick access to service restarts.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Restart display service</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix.service</code>.</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-restart-display" onclick="toolsAction('restart_display_service', 'btn-restart-display', 'result-restart-display')"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-sync-alt mr-2"></i>Restart Display
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="result-restart-display" class="hidden"></div>
|
||||||
|
|
||||||
|
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Restart web interface</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix-web.service</code>. The page will go offline briefly.</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-restart-web" onclick="toolsAction('restart_web_service', 'btn-restart-web', 'result-restart-web')"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-globe mr-2"></i>Restart Web
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="result-restart-web" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setBusy(btnId, busy) {
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.disabled = busy;
|
||||||
|
btn.style.opacity = busy ? '0.6' : '';
|
||||||
|
btn.style.cursor = busy ? 'wait' : '';
|
||||||
|
const icon = btn.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
if (busy) {
|
||||||
|
icon.dataset.origClass = icon.className;
|
||||||
|
icon.className = 'fas fa-spinner fa-spin mr-2';
|
||||||
|
} else if (icon.dataset.origClass) {
|
||||||
|
icon.className = icon.dataset.origClass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(resultId, ok, message, output, pluginDetails) {
|
||||||
|
const el = document.getElementById(resultId);
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
|
||||||
|
const color = ok ? 'green' : 'red';
|
||||||
|
const icon = ok ? 'fa-check-circle' : 'fa-times-circle';
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="mt-3 rounded-md p-3 bg-${color}-50 border border-${color}-200">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i class="fas ${icon} text-${color}-600 mt-0.5"></i>
|
||||||
|
<span class="text-sm text-${color}-800">${escHtml(message)}</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
html += `
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary class="text-xs text-${color}-700 cursor-pointer hover:underline">Show output</summary>
|
||||||
|
<pre class="mt-2 text-xs bg-gray-900 text-gray-100 rounded p-3 overflow-x-auto whitespace-pre-wrap">${escHtml(output)}</pre>
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginDetails && pluginDetails.length > 0) {
|
||||||
|
html += `<ul class="mt-3 space-y-1">`;
|
||||||
|
for (const d of pluginDetails) {
|
||||||
|
const dc = d.ok ? 'green' : 'red';
|
||||||
|
const di = d.ok ? 'fa-check' : 'fa-times';
|
||||||
|
html += `<li class="text-xs flex items-start gap-1">
|
||||||
|
<i class="fas ${di} text-${dc}-600 mt-0.5 w-3"></i>
|
||||||
|
<span class="text-gray-700">${escHtml(d.plugin)}</span>`;
|
||||||
|
if (d.output) {
|
||||||
|
html += ` <details class="inline"><summary class="cursor-pointer text-gray-400 hover:underline ml-1">output</summary>
|
||||||
|
<pre class="mt-1 text-xs bg-gray-900 text-gray-100 rounded p-2 overflow-x-auto whitespace-pre-wrap">${escHtml(d.output)}</pre></details>`;
|
||||||
|
}
|
||||||
|
html += `</li>`;
|
||||||
|
}
|
||||||
|
html += `</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main action dispatcher ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window.toolsAction = function(action, btnId, resultId, showOutput, showPluginDetails) {
|
||||||
|
setBusy(btnId, true);
|
||||||
|
const el = document.getElementById(resultId);
|
||||||
|
if (el) el.classList.add('hidden');
|
||||||
|
|
||||||
|
fetch('/api/v3/system/action', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({action})
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
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 => {
|
||||||
|
const ok = data.status === 'success';
|
||||||
|
showResult(
|
||||||
|
resultId, ok,
|
||||||
|
data.message || (ok ? 'Done' : 'Failed'),
|
||||||
|
showOutput ? (data.output || '') : '',
|
||||||
|
showPluginDetails ? (data.details || []) : null
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
showResult(resultId, false, 'Request failed: ' + err.message);
|
||||||
|
})
|
||||||
|
.finally(() => setBusy(btnId, false));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── force-reset confirm helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
window.showForceResetConfirm = function() {
|
||||||
|
document.getElementById('force-reset-confirm-row').classList.remove('hidden');
|
||||||
|
document.getElementById('btn-force-reset-confirm').classList.add('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.hideForceResetConfirm = function() {
|
||||||
|
document.getElementById('force-reset-confirm-row').classList.add('hidden');
|
||||||
|
document.getElementById('btn-force-reset-confirm').classList.remove('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── git info panel ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadGitInfo() {
|
||||||
|
const panel = document.getElementById('git-info-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
fetch('/api/v3/system/git-info')
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) return r.json().then(d => Promise.reject(d.message || `HTTP ${r.status}`));
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(d => {
|
||||||
|
if (d.status === 'error') {
|
||||||
|
panel.innerHTML = `<span class="text-sm text-red-600">${escHtml(d.message || 'Git info unavailable.')}</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const dirtyBadge = d.dirty
|
||||||
|
? '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">dirty</span>'
|
||||||
|
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">clean</span>';
|
||||||
|
|
||||||
|
let html = `<div class="space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-code-branch text-gray-400"></i>
|
||||||
|
<span class="font-mono text-gray-800">${escHtml(d.branch || 'unknown')}</span>
|
||||||
|
${dirtyBadge}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (d.dirty && d.status) {
|
||||||
|
html += `<pre class="text-xs bg-yellow-50 border border-yellow-200 rounded p-2 overflow-x-auto whitespace-pre-wrap text-yellow-900">${escHtml(d.status)}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.recent_commits) {
|
||||||
|
html += `<div class="mt-2">
|
||||||
|
<p class="text-xs text-gray-500 mb-1">Recent commits</p>
|
||||||
|
<pre class="text-xs bg-gray-50 border border-gray-200 rounded p-2 overflow-x-auto whitespace-pre-wrap text-gray-700">${escHtml(d.recent_commits)}</pre>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.remote_url) {
|
||||||
|
html += `<p class="text-xs text-gray-400 mt-1"><i class="fas fa-cloud mr-1"></i>${escHtml(d.remote_url)}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
panel.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
panel.innerHTML = `<span class="text-sm text-red-600">Could not load git info: ${escHtml(String(err))}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load on first render; HTMX will have already swapped us in by this point.
|
||||||
|
loadGitInfo();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user