mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-20 11:38:37 +00:00
Compare commits
7 Commits
d297dd6217
...
feat/tools
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2524e918d | ||
|
|
eb6687ceca | ||
|
|
60b64144a5 | ||
|
|
5a1a095e16 | ||
|
|
6aec2d9b78 | ||
|
|
6c9a3510ee | ||
|
|
6ea9862c14 |
@@ -15,8 +15,8 @@ on_error() {
|
||||
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
||||
if [ -n "${LOG_FILE:-}" ]; then
|
||||
echo "See the log for details: $LOG_FILE" >&2
|
||||
echo "-- Last 50 lines from log --" >&2
|
||||
tail -n 50 "$LOG_FILE" >&2 || true
|
||||
echo "-- Last 100 lines from log --" >&2
|
||||
tail -n 100 "$LOG_FILE" >&2 || true
|
||||
fi
|
||||
echo "\nCommon fixes:" >&2
|
||||
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
||||
@@ -202,8 +202,33 @@ retry() {
|
||||
done
|
||||
}
|
||||
|
||||
apt_update() { retry apt update; }
|
||||
apt_install() { retry apt install -y "$@"; }
|
||||
# Wait for another apt/dpkg process (commonly unattended-upgrades running
|
||||
# shortly after first boot) to release its lock before we try apt ourselves.
|
||||
# Without this, apt_update/apt_install can fail outright in the first couple
|
||||
# minutes after a fresh Pi OS boot with a generic "Command failed after 3
|
||||
# attempts" error.
|
||||
wait_for_apt_lock() {
|
||||
command -v flock >/dev/null 2>&1 || return 0
|
||||
local lock_file="/var/lib/dpkg/lock-frontend"
|
||||
local max_wait=180
|
||||
local waited=0
|
||||
local printed=0
|
||||
while ! flock -n "$lock_file" -c true 2>/dev/null; do
|
||||
if [ "$printed" -eq 0 ]; then
|
||||
echo "⚠ Waiting for another apt/dpkg process to finish (e.g. unattended-upgrades on first boot)..."
|
||||
printed=1
|
||||
fi
|
||||
if [ "$waited" -ge "$max_wait" ]; then
|
||||
echo "⚠ Still waiting after ${max_wait}s; proceeding anyway."
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited+5))
|
||||
done
|
||||
}
|
||||
|
||||
apt_update() { wait_for_apt_lock; retry apt update; }
|
||||
apt_install() { wait_for_apt_lock; retry apt install -y "$@"; }
|
||||
apt_remove() { apt-get remove -y "$@" || true; }
|
||||
|
||||
check_network() {
|
||||
@@ -222,6 +247,22 @@ check_network() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
check_disk_space() {
|
||||
command -v df >/dev/null 2>&1 || return 0
|
||||
local available_mb
|
||||
available_mb=$(df -m "$PROJECT_ROOT_DIR" | awk 'NR==2{print $4}')
|
||||
available_mb=${available_mb:-0}
|
||||
if [ "$available_mb" -lt 500 ]; then
|
||||
echo "✗ ERROR: Insufficient disk space: ${available_mb}MB available (need at least 500MB)"
|
||||
echo " Free up space first, e.g.: sudo apt clean && sudo apt autoremove"
|
||||
exit 1
|
||||
elif [ "$available_mb" -lt 1024 ]; then
|
||||
echo "⚠ Limited disk space: ${available_mb}MB available (recommend at least 1GB for the rpi-rgb-led-matrix build in Step 6)"
|
||||
else
|
||||
echo "✓ Disk space sufficient: ${available_mb}MB available"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "This script will perform the following steps:"
|
||||
echo "1. Install system dependencies"
|
||||
@@ -271,8 +312,9 @@ CURRENT_STEP="Install system dependencies"
|
||||
echo "Step 1: Installing system dependencies..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Ensure network is available before APT operations
|
||||
# Pre-flight checks before APT operations
|
||||
check_network
|
||||
check_disk_space
|
||||
|
||||
# Update package list
|
||||
apt_update
|
||||
@@ -822,14 +864,14 @@ else
|
||||
# Try to initialize submodule if .gitmodules exists
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||
if ! git submodule update --init --recursive rpi-rgb-led-matrix-master 2>&1; then
|
||||
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
fi
|
||||
else
|
||||
# Fallback: clone directly if submodule not configured
|
||||
echo "Submodule not configured, cloning directly from GitHub..."
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -841,23 +883,34 @@ else
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
rm -rf rpi-rgb-led-matrix-master
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
else
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
|
||||
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
|
||||
echo " Build deps required: python-dev-is-python3 cmake"
|
||||
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
|
||||
if ! python3 -m pip install --break-system-packages .; then
|
||||
BUILD_OUTPUT=$(mktemp)
|
||||
BUILD_SUCCESS=false
|
||||
if python3 -m pip install --break-system-packages . > "$BUILD_OUTPUT" 2>&1; then
|
||||
BUILD_SUCCESS=true
|
||||
fi
|
||||
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
|
||||
if [ "$BUILD_SUCCESS" != true ]; then
|
||||
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
|
||||
echo " Ensure build tools are installed:"
|
||||
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
|
||||
echo ""
|
||||
echo "-- Last 50 lines of build output --"
|
||||
tail -n 50 "$BUILD_OUTPUT"
|
||||
rm -f "$BUILD_OUTPUT"
|
||||
popd >/dev/null
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$BUILD_OUTPUT"
|
||||
popd >/dev/null
|
||||
else
|
||||
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
|
||||
@@ -912,7 +965,9 @@ else
|
||||
# Try to install dependencies using the smart installer if available
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
||||
echo "Using smart dependency installer..."
|
||||
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
# -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
|
||||
# real time and in order relative to this script's own echo statements
|
||||
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
else
|
||||
echo "Using pip to install dependencies..."
|
||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||
|
||||
@@ -340,9 +340,14 @@ main() {
|
||||
echo ""
|
||||
|
||||
# Execute with proper error handling and non-interactive mode
|
||||
# Temporarily disable errexit to capture exit code instead of exiting immediately
|
||||
# Temporarily disable errexit AND the ERR trap to capture exit code instead of
|
||||
# exiting immediately. `set +e` alone does not suppress the ERR trap, so without
|
||||
# `trap '' ERR` a non-zero exit from first_time_install.sh would trigger on_error
|
||||
# here with the generic "Main installation" message instead of the detailed
|
||||
# if/else handling below.
|
||||
set +e
|
||||
|
||||
trap '' ERR
|
||||
|
||||
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
|
||||
# When running manually, /tmp usually has correct permissions (1777)
|
||||
TMP_PERMS=$(stat -c '%a' /tmp 2>/dev/null || echo "unknown")
|
||||
@@ -370,6 +375,7 @@ main() {
|
||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
|
||||
fi
|
||||
INSTALL_EXIT_CODE=$?
|
||||
trap 'on_error $LINENO' ERR # Re-enable ERR trap
|
||||
set -e # Re-enable errexit
|
||||
|
||||
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
|
||||
|
||||
@@ -6,46 +6,67 @@ then falls back to pip with --break-system-packages
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
# 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,
|
||||
# which is where first_time_install.sh's error handler tails from.
|
||||
ERROR_TAIL_LINES = 15
|
||||
|
||||
|
||||
def _run(cmd):
|
||||
"""Run a command, streaming combined stdout/stderr to a temp file.
|
||||
|
||||
Returns (success, output) instead of raising, so callers can report
|
||||
*why* a command failed rather than just that it failed. `output` is
|
||||
bounded to the last ERROR_TAIL_LINES lines so failures from very
|
||||
chatty commands (e.g. pip build logs) don't get buffered in memory.
|
||||
"""
|
||||
with tempfile.TemporaryFile(mode='w+b') as f:
|
||||
result = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT) # nosec B603 B607 - hardcoded apt/pip args # nosemgrep
|
||||
f.seek(0)
|
||||
# Stream line-by-line so only the last ERROR_TAIL_LINES are ever held
|
||||
# in memory, regardless of how much output the command produced.
|
||||
tail = deque(
|
||||
(line.decode('utf-8', errors='replace').rstrip('\n') for line in f),
|
||||
maxlen=ERROR_TAIL_LINES,
|
||||
)
|
||||
return result.returncode == 0, '\n'.join(tail)
|
||||
|
||||
|
||||
def install_via_apt(package_name):
|
||||
"""Try to install a package via apt."""
|
||||
try:
|
||||
# Map pip package names to apt package names
|
||||
apt_package_map = {
|
||||
'flask': 'python3-flask',
|
||||
'PIL': 'python3-pil',
|
||||
'freetype': 'python3-freetype',
|
||||
'psutil': 'python3-psutil',
|
||||
'werkzeug': 'python3-werkzeug',
|
||||
'numpy': 'python3-numpy',
|
||||
'requests': 'python3-requests',
|
||||
'python-dateutil': 'python3-dateutil',
|
||||
'pytz': 'python3-tz',
|
||||
'geopy': 'python3-geopy',
|
||||
'unidecode': 'python3-unidecode',
|
||||
'websockets': 'python3-websockets',
|
||||
'websocket-client': 'python3-websocket-client'
|
||||
}
|
||||
|
||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
||||
|
||||
print(f"Trying to install {apt_package} via apt...")
|
||||
subprocess.check_call([
|
||||
'sudo', 'apt', 'update'
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
subprocess.check_call([
|
||||
'sudo', 'apt', 'install', '-y', apt_package
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
"""Try to install a package via apt. Returns (success, output)."""
|
||||
# Map pip package names to apt package names
|
||||
apt_package_map = {
|
||||
'flask': 'python3-flask',
|
||||
'PIL': 'python3-pil',
|
||||
'freetype': 'python3-freetype',
|
||||
'psutil': 'python3-psutil',
|
||||
'werkzeug': 'python3-werkzeug',
|
||||
'numpy': 'python3-numpy',
|
||||
'requests': 'python3-requests',
|
||||
'python-dateutil': 'python3-dateutil',
|
||||
'pytz': 'python3-tz',
|
||||
'geopy': 'python3-geopy',
|
||||
'unidecode': 'python3-unidecode',
|
||||
'websockets': 'python3-websockets',
|
||||
'websocket-client': 'python3-websocket-client'
|
||||
}
|
||||
|
||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
||||
|
||||
print(f"Trying to install {apt_package} via apt...")
|
||||
success, output = _run(['sudo', 'apt', 'install', '-y', apt_package])
|
||||
if success:
|
||||
print(f"Successfully installed {apt_package} via apt")
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
print(f"Failed to install {package_name} via apt, will try pip")
|
||||
return False
|
||||
return True, ""
|
||||
|
||||
print(f"Failed to install {apt_package} via apt, will try pip")
|
||||
return False, output
|
||||
|
||||
|
||||
def install_via_pip(package_name):
|
||||
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
||||
@@ -54,34 +75,65 @@ def install_via_pip(package_name):
|
||||
Debian/Ubuntu-based systems without a virtual environment.
|
||||
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
||||
exhausting /tmp space during compilation.
|
||||
|
||||
Returns (success, output).
|
||||
"""
|
||||
try:
|
||||
print(f"Installing {package_name} via pip...")
|
||||
subprocess.check_call([
|
||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
||||
])
|
||||
print(f"Installing {package_name} via pip...")
|
||||
success, output = _run([
|
||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
||||
])
|
||||
if success:
|
||||
print(f"Successfully installed {package_name} via pip")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Failed to install {package_name} via pip: {e}")
|
||||
return False
|
||||
return True, ""
|
||||
|
||||
print(f"Failed to install {package_name} via pip (see failure summary at end of log)")
|
||||
return False, output
|
||||
|
||||
|
||||
# Distribution (pip/apt) names whose importable module name differs.
|
||||
IMPORT_NAME_MAP = {
|
||||
'python-dateutil': 'dateutil',
|
||||
'websocket-client': 'websocket',
|
||||
}
|
||||
|
||||
|
||||
def check_package_installed(package_name):
|
||||
"""Check if a package is already installed."""
|
||||
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
|
||||
# Suppress deprecation warnings when checking if packages are installed
|
||||
# (we're just checking, not using them)
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||
try:
|
||||
__import__(package_name)
|
||||
__import__(import_name)
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def print_failure_summary(failed_packages, failure_details):
|
||||
print("\n" + "=" * 60)
|
||||
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
|
||||
print("=" * 60)
|
||||
for package in failed_packages:
|
||||
print(f"\nPackage: {package}")
|
||||
print("-" * 40)
|
||||
output = failure_details.get(package, "").strip()
|
||||
if not output:
|
||||
print(" (no output captured)")
|
||||
continue
|
||||
for line in output.splitlines()[-ERROR_TAIL_LINES:]:
|
||||
print(f" {line}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main installation function."""
|
||||
print("Installing dependencies for LED Matrix Web Interface V2...")
|
||||
|
||||
|
||||
print("Refreshing apt package index...")
|
||||
_run(['sudo', 'apt', 'update']) # best-effort; individual installs surface their own errors
|
||||
|
||||
# List of required packages
|
||||
required_packages = [
|
||||
'flask',
|
||||
@@ -98,19 +150,23 @@ def main():
|
||||
'websockets',
|
||||
'websocket-client'
|
||||
]
|
||||
|
||||
|
||||
failed_packages = []
|
||||
|
||||
failure_details = {}
|
||||
|
||||
for package in required_packages:
|
||||
if check_package_installed(package):
|
||||
print(f"{package} is already installed")
|
||||
continue
|
||||
|
||||
|
||||
# Try apt first, then pip
|
||||
if not install_via_apt(package):
|
||||
if not install_via_pip(package):
|
||||
ok, apt_output = install_via_apt(package)
|
||||
if not ok:
|
||||
ok, pip_output = install_via_pip(package)
|
||||
if not ok:
|
||||
failed_packages.append(package)
|
||||
|
||||
failure_details[package] = pip_output or apt_output
|
||||
|
||||
# Install packages that don't have apt equivalents
|
||||
special_packages = [
|
||||
'timezonefinder>=6.5.0,<7.0.0',
|
||||
@@ -122,47 +178,49 @@ def main():
|
||||
'python-socketio>=5.11.0,<6.0.0',
|
||||
'python-engineio>=4.9.0,<5.0.0'
|
||||
]
|
||||
|
||||
|
||||
for package in special_packages:
|
||||
if not install_via_pip(package):
|
||||
ok, pip_output = install_via_pip(package)
|
||||
if not ok:
|
||||
failed_packages.append(package)
|
||||
|
||||
failure_details[package] = pip_output
|
||||
|
||||
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
|
||||
# Check if already installed first
|
||||
if check_package_installed('rgbmatrix'):
|
||||
print("rgbmatrix module already installed, skipping...")
|
||||
else:
|
||||
print("Installing rgbmatrix module from local source...")
|
||||
try:
|
||||
# Get project root (parent of scripts directory)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
||||
if rgbmatrix_path.exists():
|
||||
# Check if the module has been built (look for setup.py)
|
||||
setup_py = rgbmatrix_path / 'setup.py'
|
||||
if setup_py.exists():
|
||||
# Try installing - use regular install, not editable mode
|
||||
# This is optional for web interface and should already be installed in Step 6
|
||||
subprocess.check_call([
|
||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
# Get project root (parent of scripts directory)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
||||
if rgbmatrix_path.exists():
|
||||
# Check if the module has been built (look for setup.py)
|
||||
setup_py = rgbmatrix_path / 'setup.py'
|
||||
if setup_py.exists():
|
||||
# Try installing - use regular install, not editable mode
|
||||
# This is optional for web interface and should already be installed in Step 6
|
||||
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
|
||||
if ok:
|
||||
print("rgbmatrix module installed successfully")
|
||||
else:
|
||||
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
||||
print(" This is normal if Step 6 hasn't completed yet.")
|
||||
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
||||
# and should be installed in Step 6 of first_time_install.sh
|
||||
print("Warning: Failed to install rgbmatrix module:")
|
||||
for line in output.strip().splitlines()[-ERROR_TAIL_LINES:]:
|
||||
print(f" {line}")
|
||||
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
|
||||
print(" The web interface will work without it.")
|
||||
else:
|
||||
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
||||
# and should be installed in Step 6 of first_time_install.sh
|
||||
print(f"Warning: Failed to install rgbmatrix module: {e}")
|
||||
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
|
||||
print(" The web interface will work without it.")
|
||||
# Don't add to failed_packages since it's optional
|
||||
|
||||
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
||||
print(" This is normal if Step 6 hasn't completed yet.")
|
||||
else:
|
||||
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
||||
|
||||
if failed_packages:
|
||||
print(f"\nFailed to install the following packages: {failed_packages}")
|
||||
print("You may need to install them manually or check your system configuration.")
|
||||
print_failure_summary(failed_packages, failure_details)
|
||||
return False
|
||||
else:
|
||||
print("\nAll dependencies installed successfully!")
|
||||
|
||||
@@ -705,7 +705,8 @@ def save_main_config():
|
||||
display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping',
|
||||
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
|
||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
|
||||
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type']
|
||||
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type',
|
||||
'row_address_type']
|
||||
|
||||
if any(k in data for k in display_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -736,14 +737,23 @@ def save_main_config():
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
|
||||
|
||||
# Validate row_address_type
|
||||
if 'row_address_type' in data:
|
||||
try:
|
||||
rat_val = int(data['row_address_type'])
|
||||
if rat_val < 0 or rat_val > 4:
|
||||
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
|
||||
|
||||
# Handle hardware settings
|
||||
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||
'led_rgb_sequence', 'multiplexing', 'panel_type']:
|
||||
'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type']:
|
||||
if field in data:
|
||||
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
|
||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||
'multiplexing']:
|
||||
'multiplexing', 'row_address_type']:
|
||||
current_config['display']['hardware'][field] = int(data[field])
|
||||
else:
|
||||
current_config['display']['hardware'][field] = data[field]
|
||||
@@ -1574,6 +1584,66 @@ def execute_system_action():
|
||||
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'install_base_requirements':
|
||||
req_file = PROJECT_ROOT / 'requirements.txt'
|
||||
if not req_file.exists():
|
||||
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req_file)],
|
||||
capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'success' if result.returncode == 0 else 'error',
|
||||
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
|
||||
'output': (result.stdout + result.stderr).strip()
|
||||
})
|
||||
elif action == 'install_plugin_requirements':
|
||||
plugins_dir = Path(plugin_manager.plugins_dir) if plugin_manager else PROJECT_ROOT / 'plugin-repos'
|
||||
results = []
|
||||
if plugins_dir.exists():
|
||||
for p in sorted(plugins_dir.iterdir()):
|
||||
req = p / 'requirements.txt'
|
||||
if p.is_dir() and req.exists():
|
||||
r = subprocess.run(
|
||||
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req)],
|
||||
capture_output=True, text=True, timeout=60
|
||||
)
|
||||
results.append({
|
||||
'plugin': p.name,
|
||||
'ok': r.returncode == 0,
|
||||
'output': (r.stdout + r.stderr).strip()
|
||||
})
|
||||
ok_count = sum(1 for r in results if r['ok'])
|
||||
all_ok = all(r['ok'] for r in results) if results else True
|
||||
return jsonify({
|
||||
'status': 'success' if all_ok else 'error',
|
||||
'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found',
|
||||
'details': results
|
||||
})
|
||||
elif action == 'force_git_reset':
|
||||
project_dir = str(PROJECT_ROOT)
|
||||
fetch = subprocess.run(
|
||||
['git', 'fetch', 'origin'],
|
||||
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()})
|
||||
reset = subprocess.run(
|
||||
['git', 'reset', '--hard', 'origin/main'],
|
||||
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'success' if reset.returncode == 0 else 'error',
|
||||
'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed',
|
||||
'output': (reset.stdout + reset.stderr).strip()
|
||||
})
|
||||
elif action == 'clear_pycache':
|
||||
cleared = 0
|
||||
for d in PROJECT_ROOT.rglob('__pycache__'):
|
||||
if d.is_dir():
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
cleared += 1
|
||||
return jsonify({'status': 'success', 'message': f'Cleared {cleared} __pycache__ directories'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
||||
|
||||
@@ -1596,6 +1666,27 @@ def execute_system_action():
|
||||
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
|
||||
|
||||
@api_v3.route('/system/git-info', methods=['GET'])
|
||||
def get_git_info():
|
||||
"""Return branch, dirty state, recent commits and remote URL for the Tools tab."""
|
||||
d = str(PROJECT_ROOT)
|
||||
try:
|
||||
branch = subprocess.run(['git', 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||
status = subprocess.run(['git', 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d)
|
||||
log = subprocess.run(['git', 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||
remote = subprocess.run(['git', 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||
return jsonify({
|
||||
'branch': branch.stdout.strip(),
|
||||
'dirty': bool(status.stdout.strip()),
|
||||
'status': status.stdout.strip(),
|
||||
'recent_commits': log.stdout.strip(),
|
||||
'remote_url': remote.stdout.strip(),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("get_git_info failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
|
||||
|
||||
|
||||
@api_v3.route('/hardware/status', methods=['GET'])
|
||||
def get_hardware_status():
|
||||
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
||||
|
||||
@@ -90,6 +90,8 @@ def load_partial(partial_name):
|
||||
return _load_cache_partial()
|
||||
elif partial_name == 'operation-history':
|
||||
return _load_operation_history_partial()
|
||||
elif partial_name == 'tools':
|
||||
return _load_tools_partial()
|
||||
else:
|
||||
return "Partial not found", 404
|
||||
|
||||
@@ -448,6 +450,15 @@ def _load_operation_history_partial():
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
def _load_tools_partial():
|
||||
"""Load tools/utilities partial."""
|
||||
try:
|
||||
return render_template('v3/partials/tools.html')
|
||||
except Exception:
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
def _load_plugin_config_partial(plugin_id):
|
||||
"""
|
||||
Load plugin configuration partial - server-side rendered form.
|
||||
|
||||
@@ -1009,6 +1009,11 @@
|
||||
class="nav-tab">
|
||||
<i class="fas fa-history"></i>Operation History
|
||||
</button>
|
||||
<button @click="activeTab = 'tools'"
|
||||
:class="activeTab === 'tools' ? 'nav-tab-active' : ''"
|
||||
class="nav-tab">
|
||||
<i class="fas fa-tools"></i>Tools
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1290,6 +1295,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools tab -->
|
||||
<div x-show="activeTab === 'tools'" x-transition>
|
||||
<div id="tools-content" hx-get="/v3/partials/tools" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div class="h-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Plugin Tabs - HTMX Lazy Loading -->
|
||||
<!--
|
||||
Architecture: Server-side rendered plugin configuration forms
|
||||
|
||||
@@ -166,6 +166,18 @@
|
||||
</select>
|
||||
<p class="mt-1 text-sm text-gray-600">Special panel chipset initialization (use Standard unless your panel requires it)</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type</label>
|
||||
<select id="row_address_type" name="row_address_type" class="form-control">
|
||||
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option>
|
||||
<option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
|
||||
<option value="2" {% if main_config.display.hardware.get('row_address_type', 0)|int == 2 %}selected{% endif %}>2 - Row direct</option>
|
||||
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
|
||||
<option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
|
||||
</select>
|
||||
<p class="mt-1 text-sm text-gray-600">Row addressing scheme — leave at Default (0) unless your panel requires a specific type</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
306
web_interface/templates/v3/partials/tools.html
Normal file
306
web_interface/templates/v3/partials/tools.html
Normal file
@@ -0,0 +1,306 @@
|
||||
<div class="space-y-6" id="tools-root">
|
||||
|
||||
<!-- Git & Updates -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Git & Updates</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Inspect the current git state and pull or reset to the latest remote code.</p>
|
||||
</div>
|
||||
|
||||
<!-- Git status info -->
|
||||
<div id="git-info-panel" class="mb-6 bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm">
|
||||
<div class="animate-pulse text-gray-400">Loading git info…</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Pull latest -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Pull latest (rebase)</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Stashes local changes, runs <code class="bg-gray-100 px-1 rounded">git pull --rebase</code>, then restores the stash.</p>
|
||||
</div>
|
||||
<button id="btn-git-pull" onclick="toolsAction('git_pull', 'btn-git-pull', 'result-git-pull')"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-download mr-2"></i>Pull Latest
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-git-pull" class="hidden"></div>
|
||||
|
||||
<!-- Force reset -->
|
||||
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Force reset to <code class="bg-gray-100 px-1 rounded">origin/main</code></p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">git fetch origin</code> then <code class="bg-gray-100 px-1 rounded">git reset --hard origin/main</code>. Discards all local changes.</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex flex-col items-end gap-2">
|
||||
<button id="btn-force-reset-confirm" onclick="showForceResetConfirm()"
|
||||
class="inline-flex items-center px-3 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>Force Reset…
|
||||
</button>
|
||||
<div id="force-reset-confirm-row" class="hidden flex items-center gap-2">
|
||||
<span class="text-xs text-red-700 font-medium">This discards all local changes. Sure?</span>
|
||||
<button onclick="toolsAction('force_git_reset', 'btn-force-reset-confirm', 'result-force-reset'); hideForceResetConfirm()"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||
Yes, reset
|
||||
</button>
|
||||
<button onclick="hideForceResetConfirm()"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="result-force-reset" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Python Dependencies -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Python Dependencies</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Re-run <code class="bg-gray-100 px-1 rounded">pip install</code> to fix missing or broken packages.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Base requirements -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Reinstall base requirements</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Installs from <code class="bg-gray-100 px-1 rounded">requirements.txt</code> in the project root.</p>
|
||||
</div>
|
||||
<button id="btn-base-reqs" onclick="toolsAction('install_base_requirements', 'btn-base-reqs', 'result-base-reqs', true)"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-box mr-2"></i>Reinstall Base
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-base-reqs" class="hidden"></div>
|
||||
|
||||
<!-- Plugin requirements -->
|
||||
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Reinstall plugin requirements</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">pip install</code> for every installed plugin that has a <code class="bg-gray-100 px-1 rounded">requirements.txt</code>.</p>
|
||||
</div>
|
||||
<button id="btn-plugin-reqs" onclick="toolsAction('install_plugin_requirements', 'btn-plugin-reqs', 'result-plugin-reqs', false, true)"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-puzzle-piece mr-2"></i>Reinstall Plugin Deps
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-plugin-reqs" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Maintenance</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Housekeeping operations that don't affect config or plugins.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Clear Python cache</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Deletes all <code class="bg-gray-100 px-1 rounded">__pycache__</code> directories in the project. Useful after switching branches or debugging import issues.</p>
|
||||
</div>
|
||||
<button id="btn-clear-pycache" onclick="toolsAction('clear_pycache', 'btn-clear-pycache', 'result-clear-pycache')"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-broom mr-2"></i>Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-clear-pycache" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Services</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Quick access to service restarts.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Restart display service</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix.service</code>.</p>
|
||||
</div>
|
||||
<button id="btn-restart-display" onclick="toolsAction('restart_display_service', 'btn-restart-display', 'result-restart-display')"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-sync-alt mr-2"></i>Restart Display
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-restart-display" class="hidden"></div>
|
||||
|
||||
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Restart web interface</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix-web.service</code>. The page will go offline briefly.</p>
|
||||
</div>
|
||||
<button id="btn-restart-web" onclick="toolsAction('restart_web_service', 'btn-restart-web', 'result-restart-web')"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-globe mr-2"></i>Restart Web
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-restart-web" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function setBusy(btnId, busy) {
|
||||
const btn = document.getElementById(btnId);
|
||||
if (!btn) return;
|
||||
btn.disabled = busy;
|
||||
btn.style.opacity = busy ? '0.6' : '';
|
||||
btn.style.cursor = busy ? 'wait' : '';
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
if (busy) {
|
||||
icon.dataset.origClass = icon.className;
|
||||
icon.className = 'fas fa-spinner fa-spin mr-2';
|
||||
} else if (icon.dataset.origClass) {
|
||||
icon.className = icon.dataset.origClass;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showResult(resultId, ok, message, output, pluginDetails) {
|
||||
const el = document.getElementById(resultId);
|
||||
if (!el) return;
|
||||
el.classList.remove('hidden');
|
||||
|
||||
const color = ok ? 'green' : 'red';
|
||||
const icon = ok ? 'fa-check-circle' : 'fa-times-circle';
|
||||
|
||||
let html = `
|
||||
<div class="mt-3 rounded-md p-3 bg-${color}-50 border border-${color}-200">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas ${icon} text-${color}-600 mt-0.5"></i>
|
||||
<span class="text-sm text-${color}-800">${escHtml(message)}</span>
|
||||
</div>`;
|
||||
|
||||
if (output) {
|
||||
html += `
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-${color}-700 cursor-pointer hover:underline">Show output</summary>
|
||||
<pre class="mt-2 text-xs bg-gray-900 text-gray-100 rounded p-3 overflow-x-auto whitespace-pre-wrap">${escHtml(output)}</pre>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
if (pluginDetails && pluginDetails.length > 0) {
|
||||
html += `<ul class="mt-3 space-y-1">`;
|
||||
for (const d of pluginDetails) {
|
||||
const dc = d.ok ? 'green' : 'red';
|
||||
const di = d.ok ? 'fa-check' : 'fa-times';
|
||||
html += `<li class="text-xs flex items-start gap-1">
|
||||
<i class="fas ${di} text-${dc}-600 mt-0.5 w-3"></i>
|
||||
<span class="text-gray-700">${escHtml(d.plugin)}</span>`;
|
||||
if (d.output) {
|
||||
html += ` <details class="inline"><summary class="cursor-pointer text-gray-400 hover:underline ml-1">output</summary>
|
||||
<pre class="mt-1 text-xs bg-gray-900 text-gray-100 rounded p-2 overflow-x-auto whitespace-pre-wrap">${escHtml(d.output)}</pre></details>`;
|
||||
}
|
||||
html += `</li>`;
|
||||
}
|
||||
html += `</ul>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── main action dispatcher ────────────────────────────────────────────────
|
||||
|
||||
window.toolsAction = function(action, btnId, resultId, showOutput, showPluginDetails) {
|
||||
setBusy(btnId, true);
|
||||
const el = document.getElementById(resultId);
|
||||
if (el) el.classList.add('hidden');
|
||||
|
||||
fetch('/api/v3/system/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const ok = data.status === 'success';
|
||||
showResult(
|
||||
resultId, ok,
|
||||
data.message || (ok ? 'Done' : 'Failed'),
|
||||
showOutput ? (data.output || '') : '',
|
||||
showPluginDetails ? (data.details || []) : null
|
||||
);
|
||||
})
|
||||
.catch(err => {
|
||||
showResult(resultId, false, 'Request failed: ' + err.message);
|
||||
})
|
||||
.finally(() => setBusy(btnId, false));
|
||||
};
|
||||
|
||||
// ── force-reset confirm helpers ───────────────────────────────────────────
|
||||
|
||||
window.showForceResetConfirm = function() {
|
||||
document.getElementById('force-reset-confirm-row').classList.remove('hidden');
|
||||
document.getElementById('btn-force-reset-confirm').classList.add('hidden');
|
||||
};
|
||||
|
||||
window.hideForceResetConfirm = function() {
|
||||
document.getElementById('force-reset-confirm-row').classList.add('hidden');
|
||||
document.getElementById('btn-force-reset-confirm').classList.remove('hidden');
|
||||
};
|
||||
|
||||
// ── git info panel ────────────────────────────────────────────────────────
|
||||
|
||||
function loadGitInfo() {
|
||||
const panel = document.getElementById('git-info-panel');
|
||||
if (!panel) return;
|
||||
|
||||
fetch('/api/v3/system/git-info')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const dirtyBadge = d.dirty
|
||||
? '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">dirty</span>'
|
||||
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">clean</span>';
|
||||
|
||||
let html = `<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-code-branch text-gray-400"></i>
|
||||
<span class="font-mono text-gray-800">${escHtml(d.branch || 'unknown')}</span>
|
||||
${dirtyBadge}
|
||||
</div>`;
|
||||
|
||||
if (d.dirty && d.status) {
|
||||
html += `<pre class="text-xs bg-yellow-50 border border-yellow-200 rounded p-2 overflow-x-auto whitespace-pre-wrap text-yellow-900">${escHtml(d.status)}</pre>`;
|
||||
}
|
||||
|
||||
if (d.recent_commits) {
|
||||
html += `<div class="mt-2">
|
||||
<p class="text-xs text-gray-500 mb-1">Recent commits</p>
|
||||
<pre class="text-xs bg-gray-50 border border-gray-200 rounded p-2 overflow-x-auto whitespace-pre-wrap text-gray-700">${escHtml(d.recent_commits)}</pre>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (d.remote_url) {
|
||||
html += `<p class="text-xs text-gray-400 mt-1"><i class="fas fa-cloud mr-1"></i>${escHtml(d.remote_url)}</p>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
panel.innerHTML = html;
|
||||
})
|
||||
.catch(() => {
|
||||
panel.innerHTML = '<span class="text-sm text-red-600">Could not load git info.</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// Load on first render; HTMX will have already swapped us in by this point.
|
||||
loadGitInfo();
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user