diff --git a/app/burnin.py b/app/burnin.py index def9a55..e01f934 100644 --- a/app/burnin.py +++ b/app/burnin.py @@ -339,7 +339,7 @@ async def _execute_stages(job_id: int, stages: list[str], devname: str, drive_id await _cancel_stage(job_id, stage_name) else: await _finish_stage(job_id, stage_name, success=ok) - await _recalculate_progress(job_id, profile) + await _recalculate_progress(job_id) _push_update() if not ok: @@ -385,8 +385,8 @@ async def _stage_precheck(job_id: int, drive_id: int) -> bool: await _set_stage_error(job_id, "precheck", "Drive SMART health is FAILED — refusing to burn in") return False - if temp and temp > 60: - await _set_stage_error(job_id, "precheck", f"Drive temperature {temp}°C exceeds 60°C limit") + if temp and temp > settings.temp_crit_c: + await _set_stage_error(job_id, "precheck", f"Drive temperature {temp}°C exceeds {settings.temp_crit_c}°C limit") return False await asyncio.sleep(1) # Simulate brief check diff --git a/app/config.py b/app/config.py index 946d5e1..6d432e1 100644 --- a/app/config.py +++ b/app/config.py @@ -51,5 +51,16 @@ class Settings(BaseSettings): # Stuck-job detection: jobs running longer than this are marked 'unknown' stuck_job_hours: int = 24 + # Temperature thresholds (°C) — drives table colouring + precheck gate + temp_warn_c: int = 46 # orange warning + temp_crit_c: int = 55 # red critical (precheck refuses to start above this) + + # Bad-block tolerance — surface_validate fails if bad blocks exceed this + # (applies to real badblocks in Stage 7; ignored by mock simulation) + bad_block_threshold: int = 0 + + # Application version — used by the /api/v1/updates/check endpoint + app_version: str = "1.0.0-6d" + settings = Settings() diff --git a/app/renderer.py b/app/renderer.py index 324ff6d..e205028 100644 --- a/app/renderer.py +++ b/app/renderer.py @@ -37,9 +37,10 @@ def _format_eta(seconds: int | None) -> str: def _temp_class(celsius: int | None) -> str: if celsius is None: return "" - if celsius < 40: + from app.config import settings + if celsius < settings.temp_warn_c: return "temp-cool" - if celsius < 50: + if celsius < settings.temp_crit_c: return "temp-warm" return "temp-hot" diff --git a/app/routes.py b/app/routes.py index 0c6ef07..cd2e23e 100644 --- a/app/routes.py +++ b/app/routes.py @@ -791,18 +791,9 @@ async def settings_page( request: Request, db: aiosqlite.Connection = Depends(get_db), ): - # Read-only display values (require container restart to change) - readonly = { - "truenas_base_url": settings.truenas_base_url, - "truenas_verify_tls": settings.truenas_verify_tls, - "poll_interval_seconds": settings.poll_interval_seconds, - "stale_threshold_seconds": settings.stale_threshold_seconds, - "allowed_ips": settings.allowed_ips or "(allow all)", - "log_level": settings.log_level, - } - - # Editable values — real values for form fields (password excluded) + # Editable values — real values for form fields (secrets excluded) editable = { + # SMTP "smtp_host": settings.smtp_host, "smtp_port": settings.smtp_port, "smtp_ssl_mode": settings.smtp_ssl_mode or "starttls", @@ -814,28 +805,42 @@ async def settings_page( "smtp_daily_report_enabled": settings.smtp_daily_report_enabled, "smtp_alert_on_fail": settings.smtp_alert_on_fail, "smtp_alert_on_pass": settings.smtp_alert_on_pass, + # Webhook "webhook_url": settings.webhook_url, + # Burn-in behaviour "stuck_job_hours": settings.stuck_job_hours, "max_parallel_burnins": settings.max_parallel_burnins, + "temp_warn_c": settings.temp_warn_c, + "temp_crit_c": settings.temp_crit_c, + "bad_block_threshold": settings.bad_block_threshold, + # System settings (restart required to fully apply) + "truenas_base_url": settings.truenas_base_url, + "truenas_verify_tls": settings.truenas_verify_tls, + "poll_interval_seconds": settings.poll_interval_seconds, + "stale_threshold_seconds": settings.stale_threshold_seconds, + "allowed_ips": settings.allowed_ips, + "log_level": settings.log_level, + # Note: truenas_api_key intentionally omitted from display (sensitive) } ps = poller.get_state() return templates.TemplateResponse("settings.html", { - "request": request, - "readonly": readonly, - "editable": editable, - "smtp_enabled": bool(settings.smtp_host), - "poller": ps, + "request": request, + "editable": editable, + "smtp_enabled": bool(settings.smtp_host), + "app_version": settings.app_version, + "poller": ps, **_stale_context(ps), }) @router.post("/api/v1/settings") async def save_settings(body: dict): - """Save editable runtime settings. Password is only updated if non-empty.""" - # Don't overwrite password if client sent empty string - if "smtp_password" in body and body["smtp_password"] == "": - del body["smtp_password"] + """Save editable runtime settings. Secrets are only updated if non-empty.""" + # Don't overwrite secrets if client sent empty string + for secret_field in ("smtp_password", "truenas_api_key"): + if secret_field in body and body[secret_field] == "": + del body[secret_field] try: saved = settings_store.save(body) @@ -854,6 +859,38 @@ async def test_smtp(): return {"ok": True} +@router.get("/api/v1/updates/check") +async def check_updates(): + """Check for a newer release on Forgejo.""" + import httpx + current = settings.app_version + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.get( + "https://git.hellocomputer.xyz/api/v1/repos/brandon/truenas-burnin/releases/latest", + headers={"Accept": "application/json"}, + ) + if r.status_code == 200: + data = r.json() + latest = data.get("tag_name", "").lstrip("v") + up_to_date = not latest or latest == current + return { + "current": current, + "latest": latest or None, + "update_available": not up_to_date, + "message": None, + } + elif r.status_code == 404: + return {"current": current, "latest": None, "update_available": False, + "message": "No releases published yet"} + else: + return {"current": current, "latest": None, "update_available": False, + "message": f"Forgejo API returned {r.status_code}"} + except Exception as exc: + return {"current": current, "latest": None, "update_available": False, + "message": f"Could not reach update server: {exc}"} + + # --------------------------------------------------------------------------- # Print view (must be BEFORE /{job_id} int route) # --------------------------------------------------------------------------- diff --git a/app/settings_store.py b/app/settings_store.py index 33ec439..84d50b1 100644 --- a/app/settings_store.py +++ b/app/settings_store.py @@ -4,8 +4,8 @@ Runtime settings store — persists editable settings to /data/settings_override Changes take effect immediately (in-memory setattr on the global Settings object) and survive restarts (JSON file is loaded in main.py lifespan). -Settings that require a container restart (TrueNAS URL, poll interval, allowed IPs, etc.) -are NOT included here and are display-only on the settings page. +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 @@ -18,6 +18,7 @@ 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, @@ -29,12 +30,26 @@ _EDITABLE: dict[str, type] = { "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_SSL_MODES = {"starttls", "ssl", "plain"} +_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} def _overrides_path() -> Path: @@ -63,6 +78,18 @@ def _apply(data: dict) -> None: 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) diff --git a/app/static/app.css b/app/static/app.css index e19d68d..b85cade 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -755,6 +755,11 @@ tr:hover td { flex-direction: column; gap: 8px; pointer-events: none; + transition: bottom 0.25s ease; +} + +body.drawer-open #toast-container { + bottom: calc(45vh + 16px); } .toast { diff --git a/app/templates/history.html b/app/templates/history.html index 0f8ca6d..23c8c91 100644 --- a/app/templates/history.html +++ b/app/templates/history.html @@ -32,6 +32,7 @@ State Operator Started + Completed Duration Error @@ -54,6 +55,7 @@ {{ j.operator or '—' }} {{ j.started_at | format_dt_full }} + {{ j.finished_at | format_dt_full }} {{ j.duration_seconds | format_duration }} {% if j.error_text %} @@ -67,7 +69,7 @@ {% endfor %} {% else %} - No burn-in jobs found. + No burn-in jobs found. {% endif %} diff --git a/app/templates/settings.html b/app/templates/settings.html index aa9d0d0..ac38790 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -6,12 +6,14 @@

Settings

- API Docs + + + API Docs

Changes take effect immediately. Settings marked - restart required must be changed in .env. + restart required are saved but need a container restart to fully apply.

@@ -167,11 +169,87 @@ type="number" min="1" max="168" value="{{ editable.stuck_job_hours }}"> Jobs running longer than this → auto-marked unknown + +
+ +
+ + + Show orange above this temperature +
+ +
+ + + Show red + block burn-in start above this temperature +
+ +
+ + + Max bad blocks before surface validate fails (Stage 7) +
+ +
+
+ System + restart required to apply +
+
+
+ + + + + + + + + + +
+
+ + + + + + + + + + + +
+ + Comma-separated IPs/CIDRs. Empty = allow all. +
+ +
+
+
+
@@ -180,40 +258,6 @@
- -
-
- System - restart required to change -
-
-
- TrueNAS URL - {{ readonly.truenas_base_url }} -
-
- Verify TLS - {{ 'Yes' if readonly.truenas_verify_tls else 'No' }} -
-
- Poll Interval - {{ readonly.poll_interval_seconds }}s -
-
- Stale Threshold - {{ readonly.stale_threshold_seconds }}s -
-
- IP Allowlist - {{ readonly.allowed_ips }} -
-
- Log Level - {{ readonly.log_level }} -
-
-
- {% endblock %}