- config.py: add temp_warn_c (46°C), temp_crit_c (55°C), bad_block_threshold (0), app_version - settings_store.py: expose all new fields + system settings (truenas_base_url, api_key, poll_interval, etc.) as editable; save to JSON for persistence; add validation for log_level, poll/stale intervals, temp range - renderer.py: _temp_class() now reads temp_warn_c/temp_crit_c from settings instead of hardcoded 40/50 - burnin.py: precheck uses settings.temp_crit_c; fix NameError bug (_execute_stages referenced 'profile' that was not in scope) - routes.py: add GET /api/v1/updates/check (Forgejo releases API); settings_page passes new editable fields; save_settings skips empty truenas_api_key like smtp_password - settings.html: move system settings from read-only card into editable form; add temp/bad-block fields to Burn-In Behavior; add Check for Updates button; restart-required indicator on save - history.html: add Completed (finished_at) column next to Started - app.css: toast container shifts up when drawer is open (body.drawer-open) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
"""
|
||
Runtime settings store — persists editable settings to /data/settings_overrides.json.
|
||
|
||
Changes take effect immediately (in-memory setattr on the global Settings object)
|
||
and survive restarts (JSON file is loaded in main.py lifespan).
|
||
|
||
System settings (TrueNAS URL, poll interval, etc.) are saved to JSON but require
|
||
a container restart to fully take effect (clients/middleware are initialized at boot).
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
from pathlib import Path
|
||
|
||
from app.config import settings
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
# Field name → coerce function. Only fields listed here are accepted by save().
|
||
_EDITABLE: dict[str, type] = {
|
||
# Email / SMTP
|
||
"smtp_host": str,
|
||
"smtp_ssl_mode": str,
|
||
"smtp_timeout": int,
|
||
"smtp_user": str,
|
||
"smtp_password": str,
|
||
"smtp_from": str,
|
||
"smtp_to": str,
|
||
"smtp_daily_report_enabled": bool,
|
||
"smtp_report_hour": int,
|
||
"smtp_alert_on_fail": bool,
|
||
"smtp_alert_on_pass": bool,
|
||
# Webhook
|
||
"webhook_url": str,
|
||
# Burn-in behaviour
|
||
"stuck_job_hours": int,
|
||
"max_parallel_burnins": int,
|
||
"temp_warn_c": int,
|
||
"temp_crit_c": int,
|
||
"bad_block_threshold": int,
|
||
# System settings — saved to JSON; require container restart to fully apply
|
||
"truenas_base_url": str,
|
||
"truenas_api_key": str,
|
||
"truenas_verify_tls": bool,
|
||
"poll_interval_seconds": int,
|
||
"stale_threshold_seconds": int,
|
||
"allowed_ips": str,
|
||
"log_level": str,
|
||
}
|
||
|
||
_VALID_SSL_MODES = {"starttls", "ssl", "plain"}
|
||
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||
|
||
|
||
def _overrides_path() -> Path:
|
||
return Path(settings.db_path).parent / "settings_overrides.json"
|
||
|
||
|
||
def _coerce(key: str, raw) -> object:
|
||
coerce = _EDITABLE[key]
|
||
if coerce is bool:
|
||
if isinstance(raw, bool):
|
||
return raw
|
||
return str(raw).lower() in ("1", "true", "yes", "on")
|
||
return coerce(raw)
|
||
|
||
|
||
def _apply(data: dict) -> None:
|
||
"""Apply a dict of updates to the live settings object."""
|
||
for key, raw in data.items():
|
||
if key not in _EDITABLE:
|
||
continue
|
||
try:
|
||
val = _coerce(key, raw)
|
||
if key == "smtp_ssl_mode" and val not in _VALID_SSL_MODES:
|
||
log.warning("settings_store: invalid smtp_ssl_mode %r — ignoring", val)
|
||
continue
|
||
if key == "smtp_report_hour" and not (0 <= int(val) <= 23):
|
||
log.warning("settings_store: smtp_report_hour out of range — ignoring")
|
||
continue
|
||
if key == "log_level" and val not in _VALID_LOG_LEVELS:
|
||
log.warning("settings_store: invalid log_level %r — ignoring", val)
|
||
continue
|
||
if key in ("poll_interval_seconds", "stale_threshold_seconds") and int(val) < 1:
|
||
log.warning("settings_store: %s must be >= 1 — ignoring", key)
|
||
continue
|
||
if key in ("temp_warn_c", "temp_crit_c") and not (20 <= int(val) <= 80):
|
||
log.warning("settings_store: %s out of range (20–80) — ignoring", key)
|
||
continue
|
||
if key == "bad_block_threshold" and int(val) < 0:
|
||
log.warning("settings_store: bad_block_threshold must be >= 0 — ignoring")
|
||
continue
|
||
setattr(settings, key, val)
|
||
except (ValueError, TypeError) as exc:
|
||
log.warning("settings_store: invalid value for %s: %s", key, exc)
|
||
|
||
|
||
def init() -> None:
|
||
"""Load persisted overrides at startup. Call once from lifespan."""
|
||
path = _overrides_path()
|
||
if not path.exists():
|
||
return
|
||
try:
|
||
data = json.loads(path.read_text())
|
||
_apply(data)
|
||
log.info("settings_store: loaded %d override(s) from %s", len(data), path)
|
||
except Exception as exc:
|
||
log.warning("settings_store: could not load overrides from %s: %s", path, exc)
|
||
|
||
|
||
def save(updates: dict) -> list[str]:
|
||
"""
|
||
Validate, apply, and persist a dict of settings updates.
|
||
Returns list of keys that were actually saved.
|
||
Raises ValueError for unknown or invalid fields.
|
||
"""
|
||
accepted: dict = {}
|
||
for key, raw in updates.items():
|
||
if key not in _EDITABLE:
|
||
raise ValueError(f"Unknown or non-editable setting: {key!r}")
|
||
accepted[key] = raw
|
||
|
||
_apply(accepted)
|
||
|
||
# Persist ALL currently-applied editable values (not just the delta)
|
||
snapshot = {k: getattr(settings, k) for k in _EDITABLE}
|
||
path = _overrides_path()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(json.dumps(snapshot, indent=2))
|
||
log.info("settings_store: saved %d key(s) — snapshot written to %s", len(accepted), path)
|
||
return list(accepted.keys())
|