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
|
budget, so a rollback finishes inside the unit's 30-minute limit instead of
|
||||||
being killed mid-way.
|
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
|
## 3.4.0
|
||||||
|
|
||||||
Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down:
|
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()
|
config = config_manager.load_config()
|
||||||
print(" ✅ Config loaded")
|
print(" ✅ Config loaded")
|
||||||
|
|
||||||
autostart = config.get('web_display_autostart', False)
|
# Same rule ledmatrix-web.service applies: only an explicit false/off
|
||||||
print(f" 🔧 web_display_autostart: {autostart}")
|
# 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:
|
except Exception as e:
|
||||||
print(f" ❌ Config check failed: {e}")
|
print(f" ❌ Config check failed: {e}")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@@ -20,6 +20,38 @@ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|||||||
|
|
||||||
cd "$PROJECT_DIR"
|
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}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
echo -e "${BLUE}1. SERVICE STATUS${NC}"
|
echo -e "${BLUE}1. SERVICE STATUS${NC}"
|
||||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
@@ -41,14 +73,26 @@ if [ -f "$PROJECT_DIR/config/config.json" ]; then
|
|||||||
echo -e "${GREEN}✓ Config file found${NC}"
|
echo -e "${GREEN}✓ Config file found${NC}"
|
||||||
|
|
||||||
# Check web_display_autostart setting
|
# Check web_display_autostart setting
|
||||||
AUTOSTART=$(grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' "$PROJECT_DIR/config/config.json" | grep -o '[a-z]*$')
|
AUTOSTART=$(web_autostart_state "$PROJECT_DIR")
|
||||||
|
|
||||||
if [ "$AUTOSTART" == "true" ]; then
|
case "$AUTOSTART" in
|
||||||
echo -e "${GREEN}✓ web_display_autostart: true${NC}"
|
on\ *)
|
||||||
else
|
echo -e "${GREEN}✓ web_display_autostart: ${AUTOSTART#on }${NC}"
|
||||||
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}"
|
off\ *)
|
||||||
fi
|
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
|
else
|
||||||
echo -e "${RED}✗ Config file not found at: $PROJECT_DIR/config/config.json${NC}"
|
echo -e "${RED}✗ Config file not found at: $PROJECT_DIR/config/config.json${NC}"
|
||||||
fi
|
fi
|
||||||
@@ -63,7 +107,7 @@ declare -a REQUIRED_FILES=(
|
|||||||
"web_interface/app.py"
|
"web_interface/app.py"
|
||||||
"web_interface/start.py"
|
"web_interface/start.py"
|
||||||
"web_interface/requirements.txt"
|
"web_interface/requirements.txt"
|
||||||
"web_interface/blueprints/api_v3.py"
|
"web_interface/blueprints/api_v3/__init__.py"
|
||||||
"web_interface/blueprints/pages_v3.py"
|
"web_interface/blueprints/pages_v3.py"
|
||||||
"scripts/utils/start_web_conditionally.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"
|
echo " sudo systemctl start ledmatrix-web"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$AUTOSTART" != "true" ]; then
|
if [ "${AUTOSTART%% *}" = "off" ]; then
|
||||||
echo -e "${YELLOW}→ Enable web_display_autostart in config/config.json${NC}"
|
echo -e "${YELLOW}→ Set web_display_autostart to true in config/config.json (or remove it; missing means on)${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$ALL_FILES_OK" = false ]; then
|
if [ "$ALL_FILES_OK" = false ]; then
|
||||||
|
|||||||
+52
-11
@@ -22,6 +22,38 @@ fi
|
|||||||
|
|
||||||
PROJECT_DIR="${HOME}/LEDMatrix"
|
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 "1. Checking service status..."
|
||||||
echo "------------------------------"
|
echo "------------------------------"
|
||||||
if systemctl is-active --quiet ledmatrix-web 2>/dev/null || sudo systemctl is-active --quiet ledmatrix-web 2>/dev/null; then
|
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 "------------------------------"
|
echo "------------------------------"
|
||||||
if [ -f "${PROJECT_DIR}/config/config.json" ]; then
|
if [ -f "${PROJECT_DIR}/config/config.json" ]; then
|
||||||
echo -e "${GREEN}✓ Config file exists${NC}"
|
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")
|
AUTOSTART=$(web_autostart_state "$PROJECT_DIR")
|
||||||
if [ "$AUTOSTART" = "true" ]; then
|
case "$AUTOSTART" in
|
||||||
echo -e "${GREEN}✓ web_display_autostart is set to TRUE${NC}"
|
on\ *)
|
||||||
elif [ "$AUTOSTART" = "false" ]; then
|
echo -e "${GREEN}✓ web_display_autostart is ${AUTOSTART#on } (web UI starts)${NC}"
|
||||||
echo -e "${RED}✗ web_display_autostart is set to FALSE (web UI won't start!)${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"
|
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}"
|
default)
|
||||||
echo " Fix: Add 'web_display_autostart': true to config.json"
|
echo -e "${GREEN}✓ web_display_autostart is not set (defaults to on; web UI starts)${NC}"
|
||||||
fi
|
;;
|
||||||
|
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
|
else
|
||||||
echo -e "${RED}✗ Config file NOT FOUND at ${PROJECT_DIR}/config/config.json${NC}"
|
echo -e "${RED}✗ Config file NOT FOUND at ${PROJECT_DIR}/config/config.json${NC}"
|
||||||
fi
|
fi
|
||||||
@@ -82,7 +123,7 @@ FILES_TO_CHECK=(
|
|||||||
"web_interface/start.py"
|
"web_interface/start.py"
|
||||||
"web_interface/app.py"
|
"web_interface/app.py"
|
||||||
"web_interface/requirements.txt"
|
"web_interface/requirements.txt"
|
||||||
"web_interface/blueprints/api_v3.py"
|
"web_interface/blueprints/api_v3/__init__.py"
|
||||||
"web_interface/blueprints/pages_v3.py"
|
"web_interface/blueprints/pages_v3.py"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,7 +216,7 @@ echo "Diagnostic Summary"
|
|||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Most common issues:"
|
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 " 2. Service not enabled or not started"
|
||||||
echo " 3. Missing dependencies (Flask, etc.)"
|
echo " 3. Missing dependencies (Flask, etc.)"
|
||||||
echo " 4. Import errors in web_interface/app.py"
|
echo " 4. Import errors in web_interface/app.py"
|
||||||
|
|||||||
@@ -3,6 +3,41 @@
|
|||||||
# Exit on error
|
# Exit on error
|
||||||
set -e
|
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
|
# Get the actual user who invoked sudo
|
||||||
if [ -n "$SUDO_USER" ]; then
|
if [ -n "$SUDO_USER" ]; then
|
||||||
ACTUAL_USER="$SUDO_USER"
|
ACTUAL_USER="$SUDO_USER"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# Use this if automatic dependency installation fails
|
# Use this if automatic dependency installation fails
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
# A failed pip must fail the `pip ... | tee` pipeline below, not be hidden by tee.
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -28,15 +30,50 @@ echo ""
|
|||||||
# Get the directory where this script is located
|
# Get the directory where this script is located
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
LEDMATRIX_DIR="$(cd "$SCRIPT_DIR/.." && 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 "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 ""
|
echo ""
|
||||||
|
|
||||||
# Check if plugins directory exists
|
# Check if a plugins directory exists
|
||||||
if [ ! -d "$PLUGINS_DIR" ]; then
|
if [ ${#SCAN_DIRS[@]} -eq 0 ]; then
|
||||||
echo -e "${RED}Error: Plugins directory not found at $PLUGINS_DIR${NC}"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -47,12 +84,21 @@ echo ""
|
|||||||
PLUGINS_FOUND=0
|
PLUGINS_FOUND=0
|
||||||
PLUGINS_INSTALLED=0
|
PLUGINS_INSTALLED=0
|
||||||
PLUGINS_FAILED=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
|
if [ -d "$plugin_dir" ]; then
|
||||||
plugin_name=$(basename "$plugin_dir")
|
plugin_name=$(basename "$plugin_dir")
|
||||||
requirements_file="$plugin_dir/requirements.txt"
|
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
|
if [ -f "$requirements_file" ]; then
|
||||||
PLUGINS_FOUND=$((PLUGINS_FOUND + 1))
|
PLUGINS_FOUND=$((PLUGINS_FOUND + 1))
|
||||||
echo -e "${GREEN}Found plugin: ${plugin_name}${NC}"
|
echo -e "${GREEN}Found plugin: ${plugin_name}${NC}"
|
||||||
@@ -79,6 +125,7 @@ for plugin_dir in "$PLUGINS_DIR"/*/ ; do
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
done
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -121,19 +121,24 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 5. Check web interface
|
# 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 ==="
|
echo "=== Web Interface ==="
|
||||||
if [ -f "$PROJECT_ROOT/web_interface_v2.py" ]; then
|
WEB_PORT=5000
|
||||||
check_pass "web_interface_v2.py exists"
|
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
|
else
|
||||||
check_fail "web_interface_v2.py is missing"
|
check_fail "$web_file is missing"
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Check if web service is listening
|
# Check if web service is listening
|
||||||
if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then
|
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
|
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 5001"
|
check_pass "Web interface is listening on port $WEB_PORT"
|
||||||
else
|
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
|
fi
|
||||||
else
|
else
|
||||||
check_warn "Web service is not running (cannot check port)"
|
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 -e "${GREEN}Installation verification PASSED${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
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 "2. Check service status: sudo systemctl status ledmatrix.service"
|
||||||
echo "3. View logs: journalctl -u ledmatrix.service -f"
|
echo "3. View logs: journalctl -u ledmatrix.service -f"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
+25
-21
@@ -8,6 +8,10 @@ echo "Web UI Verification"
|
|||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# The web interface binds port 5000 (web_interface/start.py).
|
||||||
|
WEB_PORT=5000
|
||||||
|
PORT_PATTERN=":${WEB_PORT}([^0-9]|$)"
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -32,25 +36,25 @@ else
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 2. Check if port 5001 is listening
|
# 2. Check if port $WEB_PORT is listening
|
||||||
echo "2. Checking if port 5001 is listening..."
|
echo "2. Checking if port $WEB_PORT is listening..."
|
||||||
if command -v ss >/dev/null 2>&1; then
|
if command -v ss >/dev/null 2>&1; then
|
||||||
if ss -tuln 2>/dev/null | grep -q ":5001"; then
|
if ss -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"; then
|
||||||
echo -e "${GREEN}✓${NC} Port 5001 is listening"
|
echo -e "${GREEN}✓${NC} Port $WEB_PORT is listening"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Active connections on port 5001:"
|
echo "Active connections on port $WEB_PORT:"
|
||||||
ss -tuln | grep ":5001"
|
ss -tuln | grep -E "$PORT_PATTERN"
|
||||||
else
|
else
|
||||||
echo -e "${RED}✗${NC} Port 5001 is NOT listening"
|
echo -e "${RED}✗${NC} Port $WEB_PORT is NOT listening"
|
||||||
fi
|
fi
|
||||||
elif command -v netstat >/dev/null 2>&1; then
|
elif command -v netstat >/dev/null 2>&1; then
|
||||||
if netstat -tuln 2>/dev/null | grep -q ":5001"; then
|
if netstat -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"; then
|
||||||
echo -e "${GREEN}✓${NC} Port 5001 is listening"
|
echo -e "${GREEN}✓${NC} Port $WEB_PORT is listening"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Active connections on port 5001:"
|
echo "Active connections on port $WEB_PORT:"
|
||||||
netstat -tuln | grep ":5001"
|
netstat -tuln | grep -E "$PORT_PATTERN"
|
||||||
else
|
else
|
||||||
echo -e "${RED}✗${NC} Port 5001 is NOT listening"
|
echo -e "${RED}✗${NC} Port $WEB_PORT is NOT listening"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}⚠${NC} Cannot check port (ss/netstat not available)"
|
echo -e "${YELLOW}⚠${NC} Cannot check port (ss/netstat not available)"
|
||||||
@@ -59,15 +63,15 @@ echo ""
|
|||||||
|
|
||||||
# 3. Test HTTP connection
|
# 3. Test HTTP connection
|
||||||
echo "3. Testing 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
|
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:5001 2>/dev/null)
|
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
|
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then
|
||||||
echo -e "${GREEN}✓${NC} Web interface is responding (HTTP $HTTP_CODE)"
|
echo -e "${GREEN}✓${NC} Web interface is responding (HTTP $HTTP_CODE)"
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}⚠${NC} Web interface responded with HTTP $HTTP_CODE"
|
echo -e "${YELLOW}⚠${NC} Web interface responded with HTTP $HTTP_CODE"
|
||||||
fi
|
fi
|
||||||
else
|
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
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -79,7 +83,7 @@ if [ -n "$IP_ADDRESSES" ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Access web interface at:"
|
echo "Access web interface at:"
|
||||||
for ip in $IP_ADDRESSES; do
|
for ip in $IP_ADDRESSES; do
|
||||||
echo " http://$ip:5001"
|
echo " http://$ip:$WEB_PORT"
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}⚠${NC} Could not determine IP address"
|
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
|
SERVICE_RUNNING=true
|
||||||
fi
|
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
|
PORT_LISTENING=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 > /dev/null 2>&1; then
|
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:5001 2>/dev/null)
|
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
|
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then
|
||||||
HTTP_RESPONDING=true
|
HTTP_RESPONDING=true
|
||||||
fi
|
fi
|
||||||
@@ -134,7 +138,7 @@ if [ "$SERVICE_RUNNING" = true ] && [ "$PORT_LISTENING" = true ] && [ "$HTTP_RES
|
|||||||
echo ""
|
echo ""
|
||||||
echo "You can access it at:"
|
echo "You can access it at:"
|
||||||
for ip in $IP_ADDRESSES; do
|
for ip in $IP_ADDRESSES; do
|
||||||
echo " http://$ip:5001"
|
echo " http://$ip:$WEB_PORT"
|
||||||
done
|
done
|
||||||
exit 0
|
exit 0
|
||||||
elif [ "$SERVICE_RUNNING" = false ]; then
|
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"
|
echo " sudo systemctl enable ledmatrix-web.service # to start on boot"
|
||||||
exit 1
|
exit 1
|
||||||
elif [ "$PORT_LISTENING" = false ]; then
|
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 ""
|
||||||
echo "Check logs for errors:"
|
echo "Check logs for errors:"
|
||||||
echo " sudo journalctl -u ledmatrix-web.service -f"
|
echo " sudo journalctl -u ledmatrix-web.service -f"
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ from enum import Enum
|
|||||||
import queue
|
import queue
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from src.cache_manager import CacheManager
|
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
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -350,13 +357,32 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
logger.info(f"Starting background fetch for {request.sport} {request.year}")
|
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
|
# ESPN stopped accepting dates=YYYYMMDD-YYYYMMDD on 2026-09-15 and
|
||||||
# answers 400 for every sport. Re-ask in months and days rather
|
# answers 400 for every sport. Re-ask in months and days rather
|
||||||
# than let a whole season fail. See src/common/espn_dates.py.
|
# 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)
|
||||||
|
# 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)
|
data = self._fetch_in_date_chunks(request)
|
||||||
if data is None:
|
if data is None:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ nothing is listening, so it stays useful in a bare checkout.
|
|||||||
| Suite | Needs a server | Covers |
|
| 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_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_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_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 |
|
| `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);
|
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`);
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
})().catch(e => { console.error(e); process.exit(1); });
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.background_data_service import BackgroundDataService
|
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"
|
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, [])})
|
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
|
@pytest.fixture
|
||||||
def cache():
|
def cache():
|
||||||
manager = MagicMock()
|
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 not result.success
|
||||||
assert len(session.calls) == 1
|
assert len(session.calls) == 1
|
||||||
cache.set.assert_not_called()
|
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']
|
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:
|
class TestInstalledListContract:
|
||||||
"""What update-all filters on is what /plugins/installed publishes."""
|
"""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:
|
if 'plugin_system' not in current_config:
|
||||||
current_config['plugin_system'] = {}
|
current_config['plugin_system'] = {}
|
||||||
|
|
||||||
# Handle plugin system checkboxes - always set to handle unchecked state
|
# auto_discover / auto_load_enabled / development_mode are read by
|
||||||
# HTML checkboxes omit the key when unchecked, so missing key = unchecked = False
|
# nothing and no longer have General-tab toggles. The form still
|
||||||
for checkbox in ['auto_discover', 'auto_load_enabled', 'development_mode']:
|
# posts plugins_directory, so treating a missing key as an
|
||||||
current_config['plugin_system'][checkbox] = _coerce_to_bool(data.get(checkbox))
|
# 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
|
# Handle plugins_directory
|
||||||
if 'plugins_directory' in data:
|
if 'plugins_directory' in data:
|
||||||
|
|||||||
@@ -988,6 +988,7 @@ def update_plugin():
|
|||||||
)
|
)
|
||||||
|
|
||||||
current_last_updated = None
|
current_last_updated = None
|
||||||
|
current_version = None
|
||||||
current_commit = None
|
current_commit = None
|
||||||
current_branch = None
|
current_branch = None
|
||||||
|
|
||||||
@@ -997,6 +998,7 @@ def update_plugin():
|
|||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
current_last_updated = manifest.get('last_updated')
|
current_last_updated = manifest.get('last_updated')
|
||||||
|
current_version = manifest.get('version')
|
||||||
if manifest.get('local_only'):
|
if manifest.get('local_only'):
|
||||||
logger.debug("Skipping update for local-only plugin: %s", plugin_id)
|
logger.debug("Skipping update for local-only plugin: %s", plugin_id)
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
@@ -1006,7 +1008,9 @@ def update_plugin():
|
|||||||
status="skipped",
|
status="skipped",
|
||||||
details={"reason": "local_only"}
|
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:
|
except Exception as e:
|
||||||
logger.debug("Could not read local manifest for plugin: %s", e)
|
logger.debug("Could not read local manifest for plugin: %s", e)
|
||||||
|
|
||||||
@@ -1036,12 +1040,14 @@ def update_plugin():
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
updated_last_updated = current_last_updated
|
updated_last_updated = current_last_updated
|
||||||
|
updated_version = current_version
|
||||||
try:
|
try:
|
||||||
if manifest_path.exists():
|
if manifest_path.exists():
|
||||||
import json
|
import json
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
updated_last_updated = manifest.get('last_updated', current_last_updated)
|
updated_last_updated = manifest.get('last_updated', current_last_updated)
|
||||||
|
updated_version = manifest.get('version', current_version)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not read updated manifest after update: %s", 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_commit = git_info_after.get('sha')
|
||||||
updated_branch = git_info_after.get('branch') or updated_branch
|
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'
|
message = f'Plugin {plugin_id} updated successfully'
|
||||||
if current_commit and updated_commit and current_commit == updated_commit:
|
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]})'
|
message = f'Plugin {plugin_id} already up to date (commit {updated_commit[:7]})'
|
||||||
elif updated_commit:
|
elif updated_commit:
|
||||||
message = f'Plugin {plugin_id} updated to commit {updated_commit[:7]}'
|
message = f'Plugin {plugin_id} updated to commit {updated_commit[:7]}'
|
||||||
if updated_branch:
|
if updated_branch:
|
||||||
message += f' on branch {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:
|
elif updated_last_updated and updated_last_updated != current_last_updated:
|
||||||
message = f'Plugin {plugin_id} refreshed (Last Updated {updated_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
|
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]:
|
if remote_commit_short and updated_commit and remote_commit_short != updated_commit[:7]:
|
||||||
@@ -1093,14 +1112,16 @@ def update_plugin():
|
|||||||
"version": version,
|
"version": version,
|
||||||
"previous_commit": current_commit[:7] if current_commit else None,
|
"previous_commit": current_commit[:7] if current_commit else None,
|
||||||
"commit": updated_commit[:7] if updated_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(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'last_updated': updated_last_updated,
|
'last_updated': updated_last_updated,
|
||||||
'commit': updated_commit
|
'commit': updated_commit,
|
||||||
|
'update_status': update_status
|
||||||
},
|
},
|
||||||
message=message
|
message=message
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -153,31 +153,54 @@ const PluginAPI = {
|
|||||||
options.body = JSON.stringify(data);
|
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 {
|
try {
|
||||||
const response = await fetch(url, options);
|
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;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Re-throw structured errors
|
|
||||||
if (error.error_code) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// Wrap network errors
|
|
||||||
throw {
|
throw {
|
||||||
error_code: 'NETWORK_ERROR',
|
error_code: 'NETWORK_ERROR',
|
||||||
message: error.message || 'Network error',
|
message: (error && error.message) || 'Network error',
|
||||||
original_error: 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
|
// Use throttling for GET requests, immediate execution for POST/PUT/DELETE
|
||||||
|
|||||||
@@ -168,6 +168,58 @@ const PluginInstallManager = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
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');
|
showNotification('No plugins to update.', 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let updated = 0, upToDate = 0, failed = 0;
|
// Counted by install_manager.js from each answer's update_status:
|
||||||
for (const r of results) {
|
// a no-op update is "already up to date", not "updated". A cached
|
||||||
if (!r.success) {
|
// install_manager.js from before that helper gets a plain count.
|
||||||
failed++;
|
const manager = window.PluginInstallManager;
|
||||||
} else if (r.result && r.result.message && r.result.message.includes('already up to date')) {
|
const summary = (manager && typeof manager.summarizeUpdateResults === 'function')
|
||||||
upToDate++;
|
? manager.summarizeUpdateResults(results)
|
||||||
} else {
|
: (() => {
|
||||||
updated++;
|
const failed = results.filter(r => !r.success).length;
|
||||||
}
|
const checked = results.length - failed;
|
||||||
}
|
return {
|
||||||
const parts = [];
|
text: `${checked} checked` + (failed ? `, ${failed} failed` : ''),
|
||||||
if (updated > 0) parts.push(`${updated} updated`);
|
type: failed ? (checked ? 'warning' : 'error') : 'success'
|
||||||
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(summary.text, summary.type);
|
||||||
showNotification(parts.join(', '), type);
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error updating all plugins:', 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>
|
<p class="text-sm text-gray-600 mb-4">Configure the core plugin system behavior.</p>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Auto Discover -->
|
<!-- plugin_system.auto_discover, auto_load_enabled and
|
||||||
<div class="form-group" id="setting-general-auto_discover" data-setting-key="plugin_system.auto_discover">
|
development_mode used to be toggles here. Nothing reads
|
||||||
<label class="flex items-center">
|
them: plugins are always discovered and every enabled
|
||||||
<input type="checkbox"
|
plugin is loaded. Stored values are left alone. -->
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Plugins Directory -->
|
<!-- Plugins Directory -->
|
||||||
<div class="form-group" id="setting-general-plugins_directory" data-setting-key="plugin_system.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