mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
Merge audit16-core/misccode into the September 16 core audit fixes
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 <value>", "off <value>", "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
|
||||
|
||||
+53
-12
@@ -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 <value>", "off <value>", "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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
-21
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -166,44 +166,10 @@
|
||||
<p class="text-sm text-gray-600 mb-4">Configure the core plugin system behavior.</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Auto Discover -->
|
||||
<div class="form-group" id="setting-general-auto_discover" data-setting-key="plugin_system.auto_discover">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="auto_discover"
|
||||
value="true"
|
||||
{% if main_config.get('plugin_system', {}).get('auto_discover', True) %}checked{% endif %}
|
||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<span class="ml-2 text-sm font-medium text-gray-900">Auto Discover Plugins</span>
|
||||
{{ ui.help_tip('Scan the plugins directory for installed plugins each time the service starts.\nDefault: on. Leave on unless you manage plugins manually.', 'Auto Discover Plugins') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Auto Load Enabled -->
|
||||
<div class="form-group" id="setting-general-auto_load_enabled" data-setting-key="plugin_system.auto_load_enabled">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="auto_load_enabled"
|
||||
value="true"
|
||||
{% if main_config.get('plugin_system', {}).get('auto_load_enabled', True) %}checked{% endif %}
|
||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<span class="ml-2 text-sm font-medium text-gray-900">Auto Load Enabled Plugins</span>
|
||||
{{ ui.help_tip('Load every plugin marked enabled in the configuration at startup.\nDefault: on. Turn off to keep plugins installed but dormant.', 'Auto Load Enabled Plugins') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Development Mode -->
|
||||
<div class="form-group" id="setting-general-development_mode" data-setting-key="plugin_system.development_mode">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="development_mode"
|
||||
value="true"
|
||||
{% if main_config.get('plugin_system', {}).get('development_mode', False) %}checked{% endif %}
|
||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<span class="ml-2 text-sm font-medium text-gray-900">Development Mode</span>
|
||||
{{ ui.help_tip('Enable verbose logging and developer features for plugin debugging.\nDefault: off. Keep off for normal use — it increases log volume.', 'Development Mode') }}
|
||||
</label>
|
||||
</div>
|
||||
<!-- plugin_system.auto_discover, auto_load_enabled and
|
||||
development_mode used to be toggles here. Nothing reads
|
||||
them: plugins are always discovered and every enabled
|
||||
plugin is loaded. Stored values are left alone. -->
|
||||
|
||||
<!-- Plugins Directory -->
|
||||
<div class="form-group" id="setting-general-plugins_directory" data-setting-key="plugin_system.plugins_directory">
|
||||
|
||||
Reference in New Issue
Block a user