diff --git a/CHANGELOG.md b/CHANGELOG.md index e516de35..dbab87b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -240,6 +240,44 @@ Automatic updates and Update Code: budget, so a rollback finishes inside the unit's 30-minute limit instead of being killed mid-way. +Small fixes (update-all, plugin system settings, scripts): + +- **Check & Update All** counts a plugin that had nothing to update as + "already up to date" instead of "updated". ZIP-installed monorepo plugins + (most official ones) already at the registry version were called "updated + successfully" on every run. `POST /plugins/update` now returns + `data.update_status` (`updated`, `up_to_date`, `local_only`). +- An update request that got an HTTP error answer without an `error_code`, or + a body that is not JSON (e.g. a reverse proxy's 502 page), is no longer + classified as `NETWORK_ERROR` and re-sent five times. Only a request that got + no HTTP answer is retried; the rest are `API_ERROR` with the HTTP status. +- The General tab no longer shows Auto Discover Plugins, Auto Load Enabled + Plugins or Development Mode. Nothing read `plugin_system.auto_discover`, + `auto_load_enabled` or `development_mode`: every enabled plugin was always + discovered and loaded. Stored values are kept, and saving the General tab no + longer rewrites them to `false`. +- `BackgroundDataService` shares the 6-hour "ESPN rejects date ranges" memo + with `fetch_espn_scoreboard`, so a background season fetch no longer spends a + doomed range request first once either path has seen a rejection. +- `scripts/install_plugin_dependencies.sh` installs from the configured + `plugin_system.plugins_directory` (default `plugin-repos`, where the Plugin + Store installs) and also scans `plugins/` for dev symlinks. It used to scan + only `plugins/` and find nothing. A failed `pip install` is now reported as a + failure instead of being hidden by `tee`. +- `scripts/verify_installation.sh` no longer fails a healthy install: it + checked for the removed `web_interface_v2.py` and port 5001. It and + `scripts/verify_web_ui.sh` now check port 5000, where the web interface + listens. +- `scripts/install/install_service.sh --help` prints usage and exits without + changes. It used to ignore the flag and reinstall and restart every service. + Unknown arguments are rejected before anything runs. +- `scripts/diagnose_web_ui.sh`, `scripts/diagnose_web_interface.sh` and + `scripts/debug/debug_web_manual.py` apply the launcher's own autostart rule + (only an explicit `web_display_autostart: false` keeps the web interface + down), so a missing key no longer shows as disabled. The shell scripts also + check `web_interface/blueprints/api_v3/`, which became a package, instead of + reporting `api_v3.py` as missing. + ## 3.4.0 Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down: diff --git a/scripts/debug/debug_web_manual.py b/scripts/debug/debug_web_manual.py index 62620946..387a4300 100644 --- a/scripts/debug/debug_web_manual.py +++ b/scripts/debug/debug_web_manual.py @@ -54,8 +54,13 @@ def main(): config = config_manager.load_config() print(" ✅ Config loaded") - autostart = config.get('web_display_autostart', False) - print(f" 🔧 web_display_autostart: {autostart}") + # Same rule ledmatrix-web.service applies: only an explicit false/off + # keeps the web interface down; a missing key means on. + sys.path.insert(0, str(project_root / 'scripts' / 'utils')) + from start_web_conditionally import autostart_enabled + raw = config.get('web_display_autostart', '(not set, defaults to on)') + state = 'starts' if autostart_enabled(config) else 'will NOT start' + print(f" 🔧 web_display_autostart: {raw} (web interface {state})") except Exception as e: print(f" ❌ Config check failed: {e}") traceback.print_exc() diff --git a/scripts/diagnose_web_interface.sh b/scripts/diagnose_web_interface.sh index b9b304c3..4bc3dd70 100755 --- a/scripts/diagnose_web_interface.sh +++ b/scripts/diagnose_web_interface.sh @@ -20,6 +20,38 @@ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$PROJECT_DIR" +# Report web_display_autostart the way scripts/utils/start_web_conditionally.py +# (what ledmatrix-web.service runs) decides it: only an explicit false/off keeps +# the web interface down; a missing key or an unreadable config starts it. +# Prints "on ", "off ", "default" (key not set) or "unreadable". +web_autostart_state() { + (cd "$1" && python3 - 2>/dev/null <<'PY' +import json, os, sys +sys.path.insert(0, os.path.join(os.getcwd(), "scripts", "utils")) +try: + from start_web_conditionally import autostart_enabled +except Exception: + def autostart_enabled(config): + value = config.get("web_display_autostart", True) + if isinstance(value, str): + return value.strip().lower() not in ("off", "false", "no", "0") + return bool(value) +try: + with open(os.path.join("config", "config.json"), encoding="utf-8") as f: + config = json.load(f) +except Exception: + config = None +if not isinstance(config, dict): + print("unreadable") +elif "web_display_autostart" not in config: + print("default") +else: + raw = json.dumps(config["web_display_autostart"]) + print(("on " if autostart_enabled(config) else "off ") + raw) +PY + ) || echo "unknown" +} + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${BLUE}1. SERVICE STATUS${NC}" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" @@ -41,14 +73,26 @@ if [ -f "$PROJECT_DIR/config/config.json" ]; then echo -e "${GREEN}✓ Config file found${NC}" # Check web_display_autostart setting - AUTOSTART=$(grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' "$PROJECT_DIR/config/config.json" | grep -o '[a-z]*$') - - if [ "$AUTOSTART" == "true" ]; then - echo -e "${GREEN}✓ web_display_autostart: true${NC}" - else - echo -e "${YELLOW}⚠ web_display_autostart: ${AUTOSTART:-not set}${NC}" - echo -e "${YELLOW} Web interface will not start unless this is set to true${NC}" - fi + AUTOSTART=$(web_autostart_state "$PROJECT_DIR") + + case "$AUTOSTART" in + on\ *) + echo -e "${GREEN}✓ web_display_autostart: ${AUTOSTART#on }${NC}" + ;; + off\ *) + echo -e "${YELLOW}⚠ web_display_autostart: ${AUTOSTART#off }${NC}" + echo -e "${YELLOW} Web interface will not start with this value${NC}" + ;; + default) + echo -e "${GREEN}✓ web_display_autostart: not set (defaults to on)${NC}" + ;; + unreadable) + echo -e "${YELLOW}⚠ config.json could not be parsed (the web interface still starts so it can be repaired)${NC}" + ;; + *) + echo -e "${YELLOW}⚠ web_display_autostart: could not be evaluated (python3 unavailable?)${NC}" + ;; + esac else echo -e "${RED}✗ Config file not found at: $PROJECT_DIR/config/config.json${NC}" fi @@ -63,7 +107,7 @@ declare -a REQUIRED_FILES=( "web_interface/app.py" "web_interface/start.py" "web_interface/requirements.txt" - "web_interface/blueprints/api_v3.py" + "web_interface/blueprints/api_v3/__init__.py" "web_interface/blueprints/pages_v3.py" "scripts/utils/start_web_conditionally.py" ) @@ -134,8 +178,8 @@ if ! sudo systemctl is-active --quiet ledmatrix-web; then echo " sudo systemctl start ledmatrix-web" fi -if [ "$AUTOSTART" != "true" ]; then - echo -e "${YELLOW}→ Enable web_display_autostart in config/config.json${NC}" +if [ "${AUTOSTART%% *}" = "off" ]; then + echo -e "${YELLOW}→ Set web_display_autostart to true in config/config.json (or remove it; missing means on)${NC}" fi if [ "$ALL_FILES_OK" = false ]; then diff --git a/scripts/diagnose_web_ui.sh b/scripts/diagnose_web_ui.sh index e2226f1a..1bc26306 100755 --- a/scripts/diagnose_web_ui.sh +++ b/scripts/diagnose_web_ui.sh @@ -22,6 +22,38 @@ fi PROJECT_DIR="${HOME}/LEDMatrix" +# Report web_display_autostart the way scripts/utils/start_web_conditionally.py +# (what ledmatrix-web.service runs) decides it: only an explicit false/off keeps +# the web interface down; a missing key or an unreadable config starts it. +# Prints "on ", "off ", "default" (key not set) or "unreadable". +web_autostart_state() { + (cd "$1" && python3 - 2>/dev/null <<'PY' +import json, os, sys +sys.path.insert(0, os.path.join(os.getcwd(), "scripts", "utils")) +try: + from start_web_conditionally import autostart_enabled +except Exception: + def autostart_enabled(config): + value = config.get("web_display_autostart", True) + if isinstance(value, str): + return value.strip().lower() not in ("off", "false", "no", "0") + return bool(value) +try: + with open(os.path.join("config", "config.json"), encoding="utf-8") as f: + config = json.load(f) +except Exception: + config = None +if not isinstance(config, dict): + print("unreadable") +elif "web_display_autostart" not in config: + print("default") +else: + raw = json.dumps(config["web_display_autostart"]) + print(("on " if autostart_enabled(config) else "off ") + raw) +PY + ) || echo "unknown" +} + echo "1. Checking service status..." echo "------------------------------" if systemctl is-active --quiet ledmatrix-web 2>/dev/null || sudo systemctl is-active --quiet ledmatrix-web 2>/dev/null; then @@ -47,16 +79,25 @@ echo "3. Checking configuration file..." echo "------------------------------" if [ -f "${PROJECT_DIR}/config/config.json" ]; then echo -e "${GREEN}✓ Config file exists${NC}" - AUTOSTART=$(grep -o '"web_display_autostart":\s*\(true\|false\)' "${PROJECT_DIR}/config/config.json" | grep -o '\(true\|false\)' || echo "not found") - if [ "$AUTOSTART" = "true" ]; then - echo -e "${GREEN}✓ web_display_autostart is set to TRUE${NC}" - elif [ "$AUTOSTART" = "false" ]; then - echo -e "${RED}✗ web_display_autostart is set to FALSE (web UI won't start!)${NC}" - echo " Fix: Edit config.json and set 'web_display_autostart': true" - else - echo -e "${YELLOW}⚠ web_display_autostart setting not found (defaults to false)${NC}" - echo " Fix: Add 'web_display_autostart': true to config.json" - fi + AUTOSTART=$(web_autostart_state "$PROJECT_DIR") + case "$AUTOSTART" in + on\ *) + echo -e "${GREEN}✓ web_display_autostart is ${AUTOSTART#on } (web UI starts)${NC}" + ;; + off\ *) + echo -e "${RED}✗ web_display_autostart is ${AUTOSTART#off } (web UI won't start!)${NC}" + echo " Fix: Edit config.json and set 'web_display_autostart': true" + ;; + default) + echo -e "${GREEN}✓ web_display_autostart is not set (defaults to on; web UI starts)${NC}" + ;; + unreadable) + echo -e "${YELLOW}⚠ config.json could not be parsed (the web UI still starts so it can be repaired)${NC}" + ;; + *) + echo -e "${YELLOW}⚠ Could not evaluate web_display_autostart (python3 unavailable?)${NC}" + ;; + esac else echo -e "${RED}✗ Config file NOT FOUND at ${PROJECT_DIR}/config/config.json${NC}" fi @@ -82,7 +123,7 @@ FILES_TO_CHECK=( "web_interface/start.py" "web_interface/app.py" "web_interface/requirements.txt" - "web_interface/blueprints/api_v3.py" + "web_interface/blueprints/api_v3/__init__.py" "web_interface/blueprints/pages_v3.py" ) @@ -175,7 +216,7 @@ echo "Diagnostic Summary" echo "==========================================" echo "" echo "Most common issues:" -echo " 1. web_display_autostart is false or missing in config.json" +echo " 1. web_display_autostart is set to false in config.json (a missing key means on)" echo " 2. Service not enabled or not started" echo " 3. Missing dependencies (Flask, etc.)" echo " 4. Import errors in web_interface/app.py" diff --git a/scripts/install/install_service.sh b/scripts/install/install_service.sh index 07a53832..8b129503 100755 --- a/scripts/install/install_service.sh +++ b/scripts/install/install_service.sh @@ -3,6 +3,41 @@ # Exit on error set -e +usage() { + cat <<'USAGE' +Usage: sudo ./scripts/install/install_service.sh [-h|--help] + +Installs (or reinstalls) the LEDMatrix systemd units from the templates in +systemd/, then enables and starts them: + - ledmatrix.service main display (runs as root) + - ledmatrix-web.service web interface (runs as the invoking user) + - ledmatrix-update-verify.service automatic-update health check + - ledmatrix-update-verify.path + +Existing unit files in /etc/systemd/system are overwritten. The script takes +no other options; run it with no arguments to install. + +Options: + -h, --help Show this help and exit without changing anything. +USAGE +} + +# Parse arguments before touching anything: this script rewrites and restarts +# services, so an unrecognised option must not fall through to a full install. +for arg in "$@"; do + case "$arg" in + -h|--help) + usage + exit 0 + ;; + *) + echo "ERROR: unknown option: $arg" >&2 + usage >&2 + exit 2 + ;; + esac +done + # Get the actual user who invoked sudo if [ -n "$SUDO_USER" ]; then ACTUAL_USER="$SUDO_USER" diff --git a/scripts/install_plugin_dependencies.sh b/scripts/install_plugin_dependencies.sh index fc0cf503..6e4eca09 100755 --- a/scripts/install_plugin_dependencies.sh +++ b/scripts/install_plugin_dependencies.sh @@ -3,6 +3,8 @@ # Use this if automatic dependency installation fails set -e +# A failed pip must fail the `pip ... | tee` pipeline below, not be hidden by tee. +set -o pipefail # Colors for output RED='\033[0;31m' @@ -28,15 +30,50 @@ echo "" # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" LEDMATRIX_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGINS_DIR="$LEDMATRIX_DIR/plugins" +CONFIG_FILE="$LEDMATRIX_DIR/config/config.json" + +# The Plugin Store installs into plugin_system.plugins_directory from +# config.json (default plugin-repos), resolved against the project root like +# the display and web services do. plugins/ is also scanned: it holds the +# symlinks scripts/dev/dev_plugin_setup.sh creates. +CONFIGURED_DIR="plugin-repos" +if [ -f "$CONFIG_FILE" ] && command -v python3 >/dev/null 2>&1; then + CONFIGURED_DIR="$(LEDMATRIX_CONFIG_FILE="$CONFIG_FILE" python3 -c ' +import json, os +try: + with open(os.environ["LEDMATRIX_CONFIG_FILE"], encoding="utf-8") as f: + value = (json.load(f).get("plugin_system") or {}).get("plugins_directory") +except Exception: + value = None +print(value if isinstance(value, str) and value.strip() else "plugin-repos") +' 2>/dev/null)" || CONFIGURED_DIR="plugin-repos" + [ -n "$CONFIGURED_DIR" ] || CONFIGURED_DIR="plugin-repos" +fi +case "$CONFIGURED_DIR" in + /*) PLUGINS_DIR="$CONFIGURED_DIR" ;; + *) PLUGINS_DIR="$LEDMATRIX_DIR/$CONFIGURED_DIR" ;; +esac +DEV_PLUGINS_DIR="$LEDMATRIX_DIR/plugins" echo "LEDMatrix directory: $LEDMATRIX_DIR" -echo "Plugins directory: $PLUGINS_DIR" +echo "Plugins directory: $PLUGINS_DIR (plugin_system.plugins_directory)" + +SCAN_DIRS=() +PLUGINS_DIR_REAL="" +if [ -d "$PLUGINS_DIR" ]; then + SCAN_DIRS+=("$PLUGINS_DIR") + PLUGINS_DIR_REAL="$(cd "$PLUGINS_DIR" && pwd -P)" +fi +if [ -d "$DEV_PLUGINS_DIR" ] && [ "$(cd "$DEV_PLUGINS_DIR" && pwd -P)" != "$PLUGINS_DIR_REAL" ]; then + echo "Also scanning dev plugins: $DEV_PLUGINS_DIR" + SCAN_DIRS+=("$DEV_PLUGINS_DIR") +fi echo "" -# Check if plugins directory exists -if [ ! -d "$PLUGINS_DIR" ]; then +# Check if a plugins directory exists +if [ ${#SCAN_DIRS[@]} -eq 0 ]; then echo -e "${RED}Error: Plugins directory not found at $PLUGINS_DIR${NC}" + echo "Install a plugin from the Plugin Store first, or check plugin_system.plugins_directory in $CONFIG_FILE" exit 1 fi @@ -47,12 +84,21 @@ echo "" PLUGINS_FOUND=0 PLUGINS_INSTALLED=0 PLUGINS_FAILED=0 +SEEN_PLUGIN_PATHS=" " -for plugin_dir in "$PLUGINS_DIR"/*/ ; do +for scan_dir in "${SCAN_DIRS[@]}"; do +for plugin_dir in "$scan_dir"/*/ ; do if [ -d "$plugin_dir" ]; then plugin_name=$(basename "$plugin_dir") requirements_file="$plugin_dir/requirements.txt" - + + # A dev symlink can point at a plugin already scanned; install it once. + real_plugin_dir="$(cd "$plugin_dir" && pwd -P)" + case "$SEEN_PLUGIN_PATHS" in + *" $real_plugin_dir "*) continue ;; + esac + SEEN_PLUGIN_PATHS="$SEEN_PLUGIN_PATHS$real_plugin_dir " + if [ -f "$requirements_file" ]; then PLUGINS_FOUND=$((PLUGINS_FOUND + 1)) echo -e "${GREEN}Found plugin: ${plugin_name}${NC}" @@ -79,6 +125,7 @@ for plugin_dir in "$PLUGINS_DIR"/*/ ; do fi fi done +done # Summary echo "" diff --git a/scripts/verify_installation.sh b/scripts/verify_installation.sh index 9de30bfc..ea7b34ad 100755 --- a/scripts/verify_installation.sh +++ b/scripts/verify_installation.sh @@ -121,19 +121,24 @@ fi echo "" # 5. Check web interface +# ledmatrix-web.service runs scripts/utils/start_web_conditionally.py, which +# starts web_interface/start.py; the app binds port 5000 (web_interface/start.py). echo "=== Web Interface ===" -if [ -f "$PROJECT_ROOT/web_interface_v2.py" ]; then - check_pass "web_interface_v2.py exists" -else - check_fail "web_interface_v2.py is missing" -fi +WEB_PORT=5000 +for web_file in scripts/utils/start_web_conditionally.py web_interface/start.py web_interface/app.py; do + if [ -f "$PROJECT_ROOT/$web_file" ]; then + check_pass "$web_file exists" + else + check_fail "$web_file is missing" + fi +done # Check if web service is listening if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then - if netstat -tuln 2>/dev/null | grep -q ":5001" || ss -tuln 2>/dev/null | grep -q ":5001"; then - check_pass "Web interface is listening on port 5001" + if netstat -tuln 2>/dev/null | grep -qE ":${WEB_PORT}([^0-9]|$)" || ss -tuln 2>/dev/null | grep -qE ":${WEB_PORT}([^0-9]|$)"; then + check_pass "Web interface is listening on port $WEB_PORT" else - check_warn "Web service is running but port 5001 may not be listening" + check_warn "Web service is running but port $WEB_PORT may not be listening" fi else check_warn "Web service is not running (cannot check port)" @@ -204,7 +209,7 @@ if [ "$ALL_PASSED" = true ]; then echo -e "${GREEN}Installation verification PASSED${NC}" echo "" echo "Next steps:" - echo "1. Access the web interface at: http://$(hostname -I | awk '{print $1}'):5001" + echo "1. Access the web interface at: http://$(hostname -I | awk '{print $1}'):$WEB_PORT" echo "2. Check service status: sudo systemctl status ledmatrix.service" echo "3. View logs: journalctl -u ledmatrix.service -f" exit 0 diff --git a/scripts/verify_web_ui.sh b/scripts/verify_web_ui.sh index 53927734..2dfc7628 100755 --- a/scripts/verify_web_ui.sh +++ b/scripts/verify_web_ui.sh @@ -8,6 +8,10 @@ echo "Web UI Verification" echo "==========================================" echo "" +# The web interface binds port 5000 (web_interface/start.py). +WEB_PORT=5000 +PORT_PATTERN=":${WEB_PORT}([^0-9]|$)" + # Colors GREEN='\033[0;32m' RED='\033[0;31m' @@ -32,25 +36,25 @@ else fi echo "" -# 2. Check if port 5001 is listening -echo "2. Checking if port 5001 is listening..." +# 2. Check if port $WEB_PORT is listening +echo "2. Checking if port $WEB_PORT is listening..." if command -v ss >/dev/null 2>&1; then - if ss -tuln 2>/dev/null | grep -q ":5001"; then - echo -e "${GREEN}✓${NC} Port 5001 is listening" + if ss -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"; then + echo -e "${GREEN}✓${NC} Port $WEB_PORT is listening" echo "" - echo "Active connections on port 5001:" - ss -tuln | grep ":5001" + echo "Active connections on port $WEB_PORT:" + ss -tuln | grep -E "$PORT_PATTERN" else - echo -e "${RED}✗${NC} Port 5001 is NOT listening" + echo -e "${RED}✗${NC} Port $WEB_PORT is NOT listening" fi elif command -v netstat >/dev/null 2>&1; then - if netstat -tuln 2>/dev/null | grep -q ":5001"; then - echo -e "${GREEN}✓${NC} Port 5001 is listening" + if netstat -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"; then + echo -e "${GREEN}✓${NC} Port $WEB_PORT is listening" echo "" - echo "Active connections on port 5001:" - netstat -tuln | grep ":5001" + echo "Active connections on port $WEB_PORT:" + netstat -tuln | grep -E "$PORT_PATTERN" else - echo -e "${RED}✗${NC} Port 5001 is NOT listening" + echo -e "${RED}✗${NC} Port $WEB_PORT is NOT listening" fi else echo -e "${YELLOW}⚠${NC} Cannot check port (ss/netstat not available)" @@ -59,15 +63,15 @@ echo "" # 3. Test HTTP connection echo "3. Testing HTTP connection..." -if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 > /dev/null 2>&1; then - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 2>/dev/null) +if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT > /dev/null 2>&1; then + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT 2>/dev/null) if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then echo -e "${GREEN}✓${NC} Web interface is responding (HTTP $HTTP_CODE)" else echo -e "${YELLOW}⚠${NC} Web interface responded with HTTP $HTTP_CODE" fi else - echo -e "${RED}✗${NC} Cannot connect to web interface on port 5001" + echo -e "${RED}✗${NC} Cannot connect to web interface on port $WEB_PORT" fi echo "" @@ -79,7 +83,7 @@ if [ -n "$IP_ADDRESSES" ]; then echo "" echo "Access web interface at:" for ip in $IP_ADDRESSES; do - echo " http://$ip:5001" + echo " http://$ip:$WEB_PORT" done else echo -e "${YELLOW}⚠${NC} Could not determine IP address" @@ -118,12 +122,12 @@ if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then SERVICE_RUNNING=true fi -if (ss -tuln 2>/dev/null | grep -q ":5001") || (netstat -tuln 2>/dev/null | grep -q ":5001"); then +if (ss -tuln 2>/dev/null | grep -qE "$PORT_PATTERN") || (netstat -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"); then PORT_LISTENING=true fi -if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 > /dev/null 2>&1; then - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 2>/dev/null) +if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT > /dev/null 2>&1; then + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT 2>/dev/null) if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then HTTP_RESPONDING=true fi @@ -134,7 +138,7 @@ if [ "$SERVICE_RUNNING" = true ] && [ "$PORT_LISTENING" = true ] && [ "$HTTP_RES echo "" echo "You can access it at:" for ip in $IP_ADDRESSES; do - echo " http://$ip:5001" + echo " http://$ip:$WEB_PORT" done exit 0 elif [ "$SERVICE_RUNNING" = false ]; then @@ -145,7 +149,7 @@ elif [ "$SERVICE_RUNNING" = false ]; then echo " sudo systemctl enable ledmatrix-web.service # to start on boot" exit 1 elif [ "$PORT_LISTENING" = false ]; then - echo -e "${RED}✗ Service is running but port 5001 is not listening${NC}" + echo -e "${RED}✗ Service is running but port $WEB_PORT is not listening${NC}" echo "" echo "Check logs for errors:" echo " sudo journalctl -u ledmatrix-web.service -f" diff --git a/src/background_data_service.py b/src/background_data_service.py index 148232cb..de435147 100644 --- a/src/background_data_service.py +++ b/src/background_data_service.py @@ -25,7 +25,14 @@ from enum import Enum import queue from concurrent.futures import ThreadPoolExecutor from src.cache_manager import CacheManager -from src.common.espn_dates import clamp_espn_limit, fetch_espn_date_chunks +from src.common.espn_dates import ( + RANGE_RETRY_SECONDS, + _note_range_rejected, + _ranges_known_rejected, + clamp_espn_limit, + fetch_espn_date_chunks, + parse_espn_date_range, +) # Configure logging logger = logging.getLogger(__name__) @@ -350,19 +357,38 @@ class BackgroundDataService: logger.info(f"Starting background fetch for {request.sport} {request.year}") - # Perform HTTP request with retry logic - response = self._make_request_with_retry(request) - # ESPN stopped accepting dates=YYYYMMDD-YYYYMMDD on 2026-09-15 and # answers 400 for every sport. Re-ask in months and days rather # than let a whole season fail. See src/common/espn_dates.py. - if response.status_code == 400: + # The "ranges are rejected" memo is shared with + # fetch_espn_scoreboard(): once either path has seen a range + # rejected, the other skips the doomed range request too. + is_range = parse_espn_date_range(request.params.get("dates")) is not None + data = None + chunks_tried = False + if is_range and _ranges_known_rejected(): data = self._fetch_in_date_chunks(request) - if data is None: + # Every chunk failed: ask for the range itself below so the + # failure carries a real HTTP error, without re-spending chunks. + chunks_tried = data is None + + if data is None: + # Perform HTTP request with retry logic + response = self._make_request_with_retry(request) + if is_range and response.status_code == 400 and not chunks_tried: + _note_range_rejected() + logger.warning( + "ESPN rejected the date range %s (400); fetching it as " + "month/day chunks, and fetching ranges that way for the " + "next %d hours", + request.params.get("dates"), RANGE_RETRY_SECONDS // 3600, + ) + data = self._fetch_in_date_chunks(request) + if data is None: + response.raise_for_status() + else: response.raise_for_status() - else: - response.raise_for_status() - data = response.json() + data = response.json() # Validate data structure if not isinstance(data, dict): diff --git a/test/js/README.md b/test/js/README.md index c9eae3b9..691b5d08 100644 --- a/test/js/README.md +++ b/test/js/README.md @@ -35,7 +35,7 @@ nothing is listening, so it stays useful in a bare checkout. | Suite | Needs a server | Covers | |---|---|---| | `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it | -| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), and re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it | +| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real `api_client.js` classifies a proxy 502 or a JSON error without `error_code` as `API_ERROR`), and counts a no-op update as already up to date in the summary. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it | | `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata | | `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer | | `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row | diff --git a/test/js/unit/test_update_all.js b/test/js/unit/test_update_all.js index acf29099..f9c2cbbd 100644 --- a/test/js/unit/test_update_all.js +++ b/test/js/unit/test_update_all.js @@ -146,6 +146,73 @@ const noSleep = { sleep: async () => {} }; ok('...and is reported as a failure', results.find(x => x.pluginId === 'static-image').success === false); } + console.log('\nthe real api_client.js: only a missing HTTP answer is retried'); + { + // The fake PluginAPI above decides error_code itself. This runs the shipped + // client so its classification is what gets tested: a proxy's 502 page or + // a JSON 500 without error_code used to come back as NETWORK_ERROR and be + // re-sent five more times. + const PluginAPI = require(path.join(V3, 'js/plugins/api_client.js')); + const run = async (makeFetch) => { + let requests = 0, sleeps = 0; + global.fetch = async () => { requests++; return makeFetch(requests); }; + global.window = { PluginAPI, installedPlugins: [{ id: 'stock-news' }] }; + const results = await Manager.updateAll(null, { sleep: async () => { sleeps++; }, retryDelaysMs: [1, 1, 1] }); + return { requests, sleeps, result: results[0] }; + }; + const httpAnswer = (status, json) => ({ ok: status < 400, status, json }); + + let r = await run(() => httpAnswer(502, async () => { throw new SyntaxError('Unexpected token <'); })); + ok('a 502 with an HTML body is sent once', r.requests === 1 && r.sleeps === 0, r); + ok('...and is an API_ERROR carrying the status, not NETWORK_ERROR', + r.result.success === false && r.result.error.error_code === 'API_ERROR' && r.result.error.status === 502, r.result.error); + + r = await run(() => httpAnswer(500, async () => ({ status: 'error', message: 'boom' }))); + ok('a JSON 500 without error_code is sent once', r.requests === 1 && r.sleeps === 0, r); + ok('...and keeps the server message', r.result.error.message === 'boom', r.result.error); + + r = await run(() => httpAnswer(500, async () => ({ status: 'error', error_code: 'PLUGIN_UPDATE_FAILED', message: 'x' }))); + ok('a structured error is passed through unchanged', + r.requests === 1 && r.result.error.error_code === 'PLUGIN_UPDATE_FAILED', r.result.error); + + r = await run((n) => { + if (n === 1) throw new TypeError('Failed to fetch'); + return httpAnswer(200, async () => ({ status: 'success', message: 'ok', data: { update_status: 'updated' } })); + }); + ok('a fetch() that rejects is NETWORK_ERROR and re-sent', r.requests === 2 && r.sleeps === 1 && r.result.success, r); + + r = await run(() => httpAnswer(200, async () => { throw new SyntaxError('Unexpected end of JSON input'); })); + ok('an unreadable 200 is not retried either', r.requests === 1 && r.result.error.error_code === 'API_ERROR', r); + delete global.fetch; + } + + console.log('\nsummary: a no-op update is not counted as updated'); + { + const answer = (update_status, message) => ({ success: true, result: { status: 'success', message, data: { update_status } } }); + const results = [ + answer('updated', 'Plugin a updated to version 2.8.0'), + // ZIP-installed monorepo plugin already at the registry version: the + // route used to call this "updated successfully". + answer('up_to_date', 'Plugin stock-news already up to date (version 2.8.0)'), + answer('up_to_date', 'Plugin clock already up to date (commit abcdef1)'), + answer('local_only', 'Plugin mine is managed locally and does not receive registry updates'), + { success: false, error: { error_code: 'PLUGIN_UPDATE_FAILED' } }, + ]; + const s = Manager.summarizeUpdateResults(results); + ok('counts come from update_status', + s.updated === 1 && s.upToDate === 2 && s.localOnly === 1 && s.failed === 1, s); + ok('toast text names each outcome', + s.text === '1 updated, 2 already up to date, 1 managed locally, 1 failed', s.text); + ok('a failure alongside an update is a warning', s.type === 'warning', s.type); + ok('an older server that only says so in the message is still up to date', + Manager.updateOutcome({ success: true, result: { message: 'Plugin x already up to date (commit 1234567)' } }) === 'up_to_date'); + ok('a success without a status or telltale message counts as updated', + Manager.updateOutcome({ success: true, result: { message: 'Plugin x updated successfully' } }) === 'updated'); + const allNoop = Manager.summarizeUpdateResults([answer('up_to_date', ''), answer('up_to_date', '')]); + ok('nothing to do is a success toast with no "updated"', + allNoop.type === 'success' && allNoop.text === '2 already up to date', allNoop); + } + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); })().catch(e => { console.error(e); process.exit(1); }); diff --git a/test/test_background_data_service_espn_ranges.py b/test/test_background_data_service_espn_ranges.py index ab6edc5d..76acbfb3 100644 --- a/test/test_background_data_service_espn_ranges.py +++ b/test/test_background_data_service_espn_ranges.py @@ -13,7 +13,12 @@ from unittest.mock import MagicMock, patch import pytest from src.background_data_service import BackgroundDataService -from src.common.espn_dates import ESPN_MAX_LIMIT, parse_espn_date_range +from src.common import espn_dates +from src.common.espn_dates import ( + ESPN_MAX_LIMIT, + fetch_espn_scoreboard, + parse_espn_date_range, +) URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard" @@ -50,6 +55,12 @@ class RangeRejectingSession: return FakeResponse(200, {"events": self.events_by_chunk.get(dates, [])}) +@pytest.fixture(autouse=True) +def ranges_not_yet_rejected(monkeypatch): + # The "ranges are rejected" memo is process-wide; every test starts clean. + monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0) + + @pytest.fixture def cache(): manager = MagicMock() @@ -161,3 +172,42 @@ def test_a_400_on_a_single_day_is_still_a_failure(service, cache): assert not result.success assert len(session.calls) == 1 cache.set.assert_not_called() + + +def test_a_rejection_seen_by_the_service_is_remembered_for_scoreboards(service): + submit_and_wait(service, RangeRejectingSession({"202609": [{"id": "a"}]}), + "20260901-20260930") + + # A live scoreboard asking for a range next must not spend a doomed 400. + session = RangeRejectingSession({"202609": [{"id": "a"}]}) + data = fetch_espn_scoreboard(session, URL, params={"dates": "20260901-20260930"}) + assert [call["dates"] for call in session.calls] == ["202609"] + assert [event["id"] for event in data["events"]] == ["a"] + + +def test_a_rejection_seen_by_a_scoreboard_skips_the_range_in_the_service(service, cache): + fetch_espn_scoreboard(RangeRejectingSession(), URL, + params={"dates": "20260901-20260930"}) + + session = RangeRejectingSession({"202609": [{"id": "a"}]}) + result = submit_and_wait(service, session, "20260901-20261001") + assert result.success, result.error + assert [call["dates"] for call in session.calls] == ["202609", "20261001"] + + +def test_known_rejection_with_every_chunk_failing_asks_the_range_once(service, cache): + espn_dates._note_range_rejected() + session = RangeRejectingSession(fail_chunks={"202609"}) + result = submit_and_wait(service, session, "20260901-20260930") + + assert not result.success + # Chunks once, then the range for a real error -- not the chunks again. + assert [call["dates"] for call in session.calls] == ["202609", "20260901-20260930"] + cache.set.assert_not_called() + + +def test_ranges_are_tried_again_once_the_memo_expires(service, monkeypatch): + monkeypatch.setattr(espn_dates, "_ranges_rejected_until", time.monotonic() - 1) + session = RangeRejectingSession({"202609": [{"id": "a"}]}) + submit_and_wait(service, session, "20260901-20260930") + assert [call["dates"] for call in session.calls] == ["20260901-20260930", "202609"] diff --git a/test/test_plugin_system_legacy_flags.py b/test/test_plugin_system_legacy_flags.py new file mode 100644 index 00000000..46fb1aed --- /dev/null +++ b/test/test_plugin_system_legacy_flags.py @@ -0,0 +1,70 @@ +"""plugin_system.auto_discover / auto_load_enabled / development_mode. + +The General tab offered three toggles for these, with help tips promising +"plugins installed but dormant" and "verbose logging". Nothing in src/, +web_interface/ or scripts/ reads them: every enabled plugin is discovered and +loaded regardless. The toggles are gone; the keys stay tolerated in stored +configs, and saving the General tab must not rewrite them. +""" +import copy +import json +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 +from test.test_web_settings_ui import REALISTIC_CONFIG, client # noqa: F401,E402 + +LEGACY_FLAGS = ('auto_discover', 'auto_load_enabled', 'development_mode') + + +def test_the_general_tab_no_longer_offers_the_toggles(client): + body = client.get('/v3/partials/general').get_data(as_text=True) + for flag in LEGACY_FLAGS: + assert f'name="{flag}"' not in body, flag + assert f'setting-general-{flag}' not in body, flag + # The setting that does work stays. + assert 'name="plugins_directory"' in body + + +@pytest.fixture +def saved(api_v3_module, monkeypatch): + captured = {} + stored = copy.deepcopy(REALISTIC_CONFIG) + stored['plugin_system'].update( + auto_discover=True, auto_load_enabled=True, development_mode=True) + api_v3_module.api_v3.config_manager.load_config.return_value = stored + + def fake_save(_manager, config, **_kwargs): + captured['config'] = config + return True, '' + + monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save) + return captured + + +def test_saving_the_general_form_leaves_stored_flags_alone(api_v3_client, saved): + # What the General form posts now: no checkbox fields for the flags. + resp = api_v3_client.post('/api/v3/config/main', data={ + 'timezone': 'America/Chicago', 'city': 'Dallas', 'state': 'Texas', + 'country': 'US', 'plugins_directory': 'plugin-repos', + }) + assert resp.status_code == 200, resp.get_json() + plugin_system = saved['config']['plugin_system'] + # Missing used to mean "unchecked" and saved all three as false. + assert all(plugin_system[flag] is True for flag in LEGACY_FLAGS), plugin_system + assert plugin_system['plugins_directory'] == 'plugin-repos' + + +def test_an_api_client_can_still_store_a_flag(api_v3_client, saved): + resp = api_v3_client.post('/api/v3/config/main', data=json.dumps({ + 'timezone': 'America/Chicago', 'development_mode': False, + }), content_type='application/json') + assert resp.status_code == 200, resp.get_json() + plugin_system = saved['config']['plugin_system'] + assert plugin_system['development_mode'] is False + assert plugin_system['auto_discover'] is True diff --git a/test/web_interface/test_update_all_plugins.py b/test/web_interface/test_update_all_plugins.py index c9ad9b39..899092ad 100644 --- a/test/web_interface/test_update_all_plugins.py +++ b/test/web_interface/test_update_all_plugins.py @@ -80,6 +80,67 @@ class TestUpdateRouteRejectsStarlarkIds: assert [c.args[0] for c in store.update_plugin.call_args_list] == ['stock-news', 'starlark-apps'] +class TestUpdateRouteReportsNoOps: + """update_plugin() answers True when there was nothing to do. + + A ZIP-installed monorepo plugin (no .git) already at the registry version + is the common case for official plugins. The route used to call that + "updated successfully", so Check & Update All counted it as updated. + """ + + def _install(self, tmp_path, pid, version, last_updated='2026-09-01'): + (tmp_path / pid).mkdir() + (tmp_path / pid / 'manifest.json').write_text( + '{"id": "%s", "version": "%s", "last_updated": "%s"}' % (pid, version, last_updated), + encoding='utf-8') + + def test_a_zip_plugin_already_at_the_registry_version_is_up_to_date(self, client, store, tmp_path): + self._install(tmp_path, 'stock-news', '2.8.0') + body = client.post('/api/v3/plugins/update', json={'plugin_id': 'stock-news'}).get_json() + assert body['status'] == 'success' + assert body['data']['update_status'] == 'up_to_date' + assert 'already up to date' in body['message'], body + assert 'updated successfully' not in body['message'] + + def test_a_zip_plugin_reinstalled_at_a_new_version_is_updated(self, client, store, tmp_path): + self._install(tmp_path, 'stock-news', '2.6.2') + + def reinstall(pid): + (tmp_path / pid / 'manifest.json').write_text( + '{"id": "%s", "version": "2.8.0", "last_updated": "2026-09-01"}' % pid, + encoding='utf-8') + return True + store.update_plugin.side_effect = reinstall + + body = client.post('/api/v3/plugins/update', json={'plugin_id': 'stock-news'}).get_json() + assert body['data']['update_status'] == 'updated' + assert '2.8.0' in body['message'], body + + def test_a_git_plugin_whose_commit_is_unchanged_is_up_to_date(self, client, store, tmp_path): + self._install(tmp_path, 'clock', '1.0.0') + store._get_local_git_info.return_value = {'sha': 'abcdef1234567', 'branch': 'main'} + body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json() + assert body['data']['update_status'] == 'up_to_date' + + def test_a_git_plugin_that_moved_is_updated(self, client, store, tmp_path): + self._install(tmp_path, 'clock', '1.0.0') + store._get_local_git_info.side_effect = [ + {'sha': 'aaaaaaa000', 'branch': 'main'}, # before + {'sha': 'aaaaaaa000', 'branch': 'main'}, # is-git check + {'sha': 'bbbbbbb111', 'branch': 'main'}, # after + ] + body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json() + assert body['data']['update_status'] == 'updated', body + + def test_a_local_only_plugin_says_so(self, client, store, tmp_path): + (tmp_path / 'mine').mkdir() + (tmp_path / 'mine' / 'manifest.json').write_text( + '{"id": "mine", "version": "0.1.0", "local_only": true}', encoding='utf-8') + body = client.post('/api/v3/plugins/update', json={'plugin_id': 'mine'}).get_json() + assert body['data']['update_status'] == 'local_only' + store.update_plugin.assert_not_called() + + class TestInstalledListContract: """What update-all filters on is what /plugins/installed publishes.""" diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py index 64ed15ef..3416f039 100644 --- a/web_interface/blueprints/api_v3/config.py +++ b/web_interface/blueprints/api_v3/config.py @@ -522,10 +522,14 @@ def save_main_config(): if 'plugin_system' not in current_config: current_config['plugin_system'] = {} - # Handle plugin system checkboxes - always set to handle unchecked state - # HTML checkboxes omit the key when unchecked, so missing key = unchecked = False - for checkbox in ['auto_discover', 'auto_load_enabled', 'development_mode']: - current_config['plugin_system'][checkbox] = _coerce_to_bool(data.get(checkbox)) + # auto_discover / auto_load_enabled / development_mode are read by + # nothing and no longer have General-tab toggles. The form still + # posts plugins_directory, so treating a missing key as an + # unchecked box would rewrite stored values to false on every + # save; only store what a client actually sends. + for legacy_flag in ['auto_discover', 'auto_load_enabled', 'development_mode']: + if legacy_flag in data: + current_config['plugin_system'][legacy_flag] = _coerce_to_bool(data.get(legacy_flag)) # Handle plugins_directory if 'plugins_directory' in data: diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index 87d1cbd2..cb5c5058 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -988,6 +988,7 @@ def update_plugin(): ) current_last_updated = None + current_version = None current_commit = None current_branch = None @@ -997,6 +998,7 @@ def update_plugin(): with open(manifest_path, 'r', encoding='utf-8') as f: manifest = json.load(f) current_last_updated = manifest.get('last_updated') + current_version = manifest.get('version') if manifest.get('local_only'): logger.debug("Skipping update for local-only plugin: %s", plugin_id) if api_v3.operation_history: @@ -1006,7 +1008,9 @@ def update_plugin(): status="skipped", details={"reason": "local_only"} ) - return success_response(message=f'Plugin {plugin_id} is managed locally and does not receive registry updates') + return success_response( + data={'update_status': 'local_only'}, + message=f'Plugin {plugin_id} is managed locally and does not receive registry updates') except Exception as e: logger.debug("Could not read local manifest for plugin: %s", e) @@ -1036,12 +1040,14 @@ def update_plugin(): if success: updated_last_updated = current_last_updated + updated_version = current_version try: if manifest_path.exists(): import json with open(manifest_path, 'r', encoding='utf-8') as f: manifest = json.load(f) updated_last_updated = manifest.get('last_updated', current_last_updated) + updated_version = manifest.get('version', current_version) except Exception as e: logger.debug("Could not read updated manifest after update: %s", e) @@ -1053,15 +1059,28 @@ def update_plugin(): updated_commit = git_info_after.get('sha') updated_branch = git_info_after.get('branch') or updated_branch + # update_plugin() answers True for "nothing to do" as well as for + # a real update (a ZIP-installed monorepo plugin already at the + # registry version, a bundled plugin), so what changed is read off + # the plugin itself: its git commit, else its manifest. + update_status = 'updated' message = f'Plugin {plugin_id} updated successfully' if current_commit and updated_commit and current_commit == updated_commit: + update_status = 'up_to_date' message = f'Plugin {plugin_id} already up to date (commit {updated_commit[:7]})' elif updated_commit: message = f'Plugin {plugin_id} updated to commit {updated_commit[:7]}' if updated_branch: message += f' on branch {updated_branch}' + elif updated_version and updated_version != current_version: + message = f'Plugin {plugin_id} updated to version {updated_version}' elif updated_last_updated and updated_last_updated != current_last_updated: message = f'Plugin {plugin_id} refreshed (Last Updated {updated_last_updated})' + elif not current_commit: + update_status = 'up_to_date' + message = f'Plugin {plugin_id} already up to date' + if updated_version: + message += f' (version {updated_version})' remote_commit_short = remote_commit[:7] if remote_commit else None if remote_commit_short and updated_commit and remote_commit_short != updated_commit[:7]: @@ -1093,14 +1112,16 @@ def update_plugin(): "version": version, "previous_commit": current_commit[:7] if current_commit else None, "commit": updated_commit[:7] if updated_commit else None, - "branch": updated_branch + "branch": updated_branch, + "update_status": update_status } ) return success_response( data={ 'last_updated': updated_last_updated, - 'commit': updated_commit + 'commit': updated_commit, + 'update_status': update_status }, message=message ) diff --git a/web_interface/static/v3/js/plugins/api_client.js b/web_interface/static/v3/js/plugins/api_client.js index fd33ce12..0e54f03b 100644 --- a/web_interface/static/v3/js/plugins/api_client.js +++ b/web_interface/static/v3/js/plugins/api_client.js @@ -153,31 +153,54 @@ const PluginAPI = { options.body = JSON.stringify(data); } + // NETWORK_ERROR means only that fetch() itself rejected: no HTTP + // answer arrived (connection refused/reset, e.g. the web service + // restarting). Callers retry that (install_manager.js updateAll). + // Any HTTP response is the server's -- or a proxy's -- answer, so + // a 502 HTML page or a JSON error without error_code is API_ERROR + // and is not retried. + let response; try { - const response = await fetch(url, options); - const responseData = await response.json(); - - if (!response.ok) { - // Handle structured errors - if (responseData.error_code) { - throw responseData; - } - throw new Error(responseData.message || `HTTP ${response.status}`); - } - - return responseData; + response = await fetch(url, options); } catch (error) { - // Re-throw structured errors - if (error.error_code) { - throw error; - } - // Wrap network errors throw { error_code: 'NETWORK_ERROR', - message: error.message || 'Network error', + message: (error && error.message) || 'Network error', original_error: error }; } + + let responseData = null; + let parseError = null; + try { + responseData = await response.json(); + } catch (error) { + parseError = error; + } + + if (!response.ok) { + // Handle structured errors + if (responseData && responseData.error_code) { + throw responseData; + } + throw { + error_code: 'API_ERROR', + status: response.status, + message: (responseData && responseData.message) || `HTTP ${response.status}`, + original_error: parseError || undefined + }; + } + + if (parseError) { + throw { + error_code: 'API_ERROR', + status: response.status, + message: `Unreadable response from the server (HTTP ${response.status})`, + original_error: parseError + }; + } + + return responseData; }; // Use throttling for GET requests, immediate execution for POST/PUT/DELETE diff --git a/web_interface/static/v3/js/plugins/install_manager.js b/web_interface/static/v3/js/plugins/install_manager.js index 49702332..f7d284f9 100644 --- a/web_interface/static/v3/js/plugins/install_manager.js +++ b/web_interface/static/v3/js/plugins/install_manager.js @@ -168,6 +168,58 @@ const PluginInstallManager = { } return results; + }, + + /** + * Classify one POST /plugins/update answer. + * + * The route reports what actually happened in `data.update_status` + * (`updated`, `up_to_date`, `local_only`). A plugin the updater had + * nothing to do for -- e.g. a ZIP-installed monorepo plugin already at the + * registry version -- is still a success response, so it must not be + * counted as updated. Older servers only say so in the message. + * + * @param {Object} entry - One element of updateAll()'s results + * @returns {string} 'failed' | 'updated' | 'up_to_date' | 'local_only' + */ + updateOutcome(entry) { + if (!entry || !entry.success) return 'failed'; + const result = entry.result || {}; + const status = result.data && result.data.update_status; + if (status === 'up_to_date' || status === 'local_only' || status === 'updated') { + return status; + } + const message = typeof result.message === 'string' ? result.message : ''; + if (message.includes('already up to date')) return 'up_to_date'; + if (message.includes('managed locally')) return 'local_only'; + return 'updated'; + }, + + /** + * Summarise updateAll()'s results for the Check & Update All toast. + * + * @param {Array} results - updateAll()'s results + * @returns {{updated: number, upToDate: number, localOnly: number, failed: number, text: string, type: string}} + */ + summarizeUpdateResults(results) { + const counts = { updated: 0, up_to_date: 0, local_only: 0, failed: 0 }; + for (const entry of (Array.isArray(results) ? results : [])) { + counts[this.updateOutcome(entry)]++; + } + const parts = []; + if (counts.updated > 0) parts.push(`${counts.updated} updated`); + if (counts.up_to_date > 0) parts.push(`${counts.up_to_date} already up to date`); + if (counts.local_only > 0) parts.push(`${counts.local_only} managed locally`); + if (counts.failed > 0) parts.push(`${counts.failed} failed`); + const type = counts.failed > 0 ? (counts.updated > 0 ? 'warning' : 'error') : 'success'; + return { + updated: counts.updated, + upToDate: counts.up_to_date, + localOnly: counts.local_only, + failed: counts.failed, + text: parts.join(', '), + type + }; } }; diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 098014c2..091b4603 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -1999,22 +1999,21 @@ function runUpdateAllPlugins() { showNotification('No plugins to update.', 'info'); return; } - let updated = 0, upToDate = 0, failed = 0; - for (const r of results) { - if (!r.success) { - failed++; - } else if (r.result && r.result.message && r.result.message.includes('already up to date')) { - upToDate++; - } else { - updated++; - } - } - const parts = []; - if (updated > 0) parts.push(`${updated} updated`); - if (upToDate > 0) parts.push(`${upToDate} already up to date`); - if (failed > 0) parts.push(`${failed} failed`); - const type = failed > 0 ? (updated > 0 ? 'warning' : 'error') : 'success'; - showNotification(parts.join(', '), type); + // Counted by install_manager.js from each answer's update_status: + // a no-op update is "already up to date", not "updated". A cached + // install_manager.js from before that helper gets a plain count. + const manager = window.PluginInstallManager; + const summary = (manager && typeof manager.summarizeUpdateResults === 'function') + ? manager.summarizeUpdateResults(results) + : (() => { + const failed = results.filter(r => !r.success).length; + const checked = results.length - failed; + return { + text: `${checked} checked` + (failed ? `, ${failed} failed` : ''), + type: failed ? (checked ? 'warning' : 'error') : 'success' + }; + })(); + showNotification(summary.text, summary.type); }) .catch(error => { console.error('Error updating all plugins:', error); diff --git a/web_interface/templates/v3/partials/general.html b/web_interface/templates/v3/partials/general.html index 65d8da6f..0976a60e 100644 --- a/web_interface/templates/v3/partials/general.html +++ b/web_interface/templates/v3/partials/general.html @@ -166,44 +166,10 @@

Configure the core plugin system behavior.

- -
- -
- - -
- -
- - -
- -
+