Add temp thresholds, bad block threshold, editable system settings, check for updates, history completed time

- 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>
This commit is contained in:
Brandon Walter 2026-02-24 07:43:23 -05:00
parent c0f9098779
commit 4ab54d7ed8
8 changed files with 224 additions and 66 deletions

View file

@ -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

View file

@ -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()

View file

@ -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"

View file

@ -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)
# ---------------------------------------------------------------------------

View file

@ -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 (2080) — 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)

View file

@ -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 {

View file

@ -32,6 +32,7 @@
<th>State</th>
<th>Operator</th>
<th>Started</th>
<th>Completed</th>
<th>Duration</th>
<th>Error</th>
<th class="col-actions"></th>
@ -54,6 +55,7 @@
</td>
<td class="text-muted">{{ j.operator or '—' }}</td>
<td class="mono text-muted">{{ j.started_at | format_dt_full }}</td>
<td class="mono text-muted">{{ j.finished_at | format_dt_full }}</td>
<td class="mono text-muted">{{ j.duration_seconds | format_duration }}</td>
<td class="error-cell">
{% if j.error_text %}
@ -67,7 +69,7 @@
{% endfor %}
{% else %}
<tr>
<td colspan="9" class="empty-state">No burn-in jobs found.</td>
<td colspan="10" class="empty-state">No burn-in jobs found.</td>
</tr>
{% endif %}
</tbody>

View file

@ -6,12 +6,14 @@
<div class="page-toolbar">
<h1 class="page-title">Settings</h1>
<div class="toolbar-right">
<a class="btn-export" href="/docs" target="_blank" rel="noopener">API Docs</a>
<button type="button" id="check-updates-btn" class="btn-secondary">Check for Updates</button>
<span id="update-result" class="settings-test-result" style="display:none;margin-left:8px"></span>
<a class="btn-export" href="/docs" target="_blank" rel="noopener" style="margin-left:8px">API Docs</a>
</div>
</div>
<p class="page-subtitle">
Changes take effect immediately. Settings marked
<span class="badge-restart">restart required</span> must be changed in <code>.env</code>.
<span class="badge-restart">restart required</span> are saved but need a container restart to fully apply.
</p>
<form id="settings-form" autocomplete="off">
@ -167,11 +169,87 @@
type="number" min="1" max="168" value="{{ editable.stuck_job_hours }}">
<span class="sf-hint">Jobs running longer than this → auto-marked unknown</span>
</div>
<div class="sf-divider"></div>
<div class="sf-row">
<label class="sf-label" for="temp_warn_c">Temp Warning (°C)</label>
<input class="sf-input sf-input-xs" id="temp_warn_c" name="temp_warn_c"
type="number" min="20" max="80" value="{{ editable.temp_warn_c }}">
<span class="sf-hint">Show orange above this temperature</span>
</div>
<div class="sf-row">
<label class="sf-label" for="temp_crit_c">Temp Critical (°C)</label>
<input class="sf-input sf-input-xs" id="temp_crit_c" name="temp_crit_c"
type="number" min="20" max="80" value="{{ editable.temp_crit_c }}">
<span class="sf-hint">Show red + block burn-in start above this temperature</span>
</div>
<div class="sf-row">
<label class="sf-label" for="bad_block_threshold">Bad Block Threshold</label>
<input class="sf-input sf-input-xs" id="bad_block_threshold" name="bad_block_threshold"
type="number" min="0" max="9999" value="{{ editable.bad_block_threshold }}">
<span class="sf-hint">Max bad blocks before surface validate fails (Stage 7)</span>
</div>
</div>
</div><!-- /right col -->
</div><!-- /two-col -->
<!-- System settings (restart required) -->
<div class="settings-card" style="margin-top:16px">
<div class="settings-card-header">
<span class="settings-card-title">System</span>
<span class="badge-restart">restart required to apply</span>
</div>
<div class="settings-two-col" style="gap:16px">
<div class="sf-fields">
<label for="truenas_base_url">TrueNAS URL</label>
<input class="sf-input" id="truenas_base_url" name="truenas_base_url" type="text"
value="{{ editable.truenas_base_url }}" placeholder="http://10.0.0.x">
<label for="truenas_api_key">API Key</label>
<input class="sf-input" id="truenas_api_key" name="truenas_api_key" type="password"
placeholder="leave blank to keep existing" autocomplete="new-password">
<label for="truenas_verify_tls">Verify TLS</label>
<label class="toggle" style="margin-top:2px">
<input type="checkbox" id="truenas_verify_tls" name="truenas_verify_tls"
{% if editable.truenas_verify_tls %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="sf-fields">
<label for="poll_interval_seconds">Poll Interval (s)</label>
<input class="sf-input sf-input-xs" id="poll_interval_seconds" name="poll_interval_seconds"
type="number" min="1" max="300" value="{{ editable.poll_interval_seconds }}">
<label for="stale_threshold_seconds">Stale Threshold (s)</label>
<input class="sf-input sf-input-xs" id="stale_threshold_seconds" name="stale_threshold_seconds"
type="number" min="1" max="600" value="{{ editable.stale_threshold_seconds }}">
<label for="log_level">Log Level</label>
<select class="sf-select" id="log_level" name="log_level">
{% for lvl in ['DEBUG','INFO','WARNING','ERROR','CRITICAL'] %}
<option value="{{ lvl }}" {% if editable.log_level == lvl %}selected{% endif %}>{{ lvl }}</option>
{% endfor %}
</select>
<label for="allowed_ips">IP Allowlist</label>
<div>
<input class="sf-input" id="allowed_ips" name="allowed_ips" type="text"
value="{{ editable.allowed_ips }}" placeholder="10.0.0.0/24,127.0.0.1 (empty = allow all)">
<span class="sf-hint" style="margin-top:3px">Comma-separated IPs/CIDRs. Empty = allow all.</span>
</div>
</div>
</div>
</div>
<!-- Save row -->
<div class="settings-save-bar">
<button type="submit" class="btn-primary" id="save-btn">Save Settings</button>
@ -180,40 +258,6 @@
</div>
</form>
<!-- System (read-only) -->
<div class="settings-card settings-card-readonly">
<div class="settings-card-header">
<span class="settings-card-title">System</span>
<span class="badge-restart">restart required to change</span>
</div>
<div class="sf-readonly-grid">
<div class="sf-ro-row">
<span class="sf-ro-label">TrueNAS URL</span>
<span class="sf-ro-value mono">{{ readonly.truenas_base_url }}</span>
</div>
<div class="sf-ro-row">
<span class="sf-ro-label">Verify TLS</span>
<span class="sf-ro-value">{{ 'Yes' if readonly.truenas_verify_tls else 'No' }}</span>
</div>
<div class="sf-ro-row">
<span class="sf-ro-label">Poll Interval</span>
<span class="sf-ro-value mono">{{ readonly.poll_interval_seconds }}s</span>
</div>
<div class="sf-ro-row">
<span class="sf-ro-label">Stale Threshold</span>
<span class="sf-ro-value mono">{{ readonly.stale_threshold_seconds }}s</span>
</div>
<div class="sf-ro-row">
<span class="sf-ro-label">IP Allowlist</span>
<span class="sf-ro-value mono">{{ readonly.allowed_ips }}</span>
</div>
<div class="sf-ro-row">
<span class="sf-ro-label">Log Level</span>
<span class="sf-ro-value mono">{{ readonly.log_level }}</span>
</div>
</div>
</div>
<script>
(function () {
// Dim report-hour when daily report disabled
@ -260,7 +304,12 @@
});
var data = await resp.json();
if (resp.ok) {
showResult(saveResult, true, 'Saved');
// Show restart notice if any system settings were saved
var systemFields = ['truenas_base_url','truenas_api_key','truenas_verify_tls',
'poll_interval_seconds','stale_threshold_seconds','allowed_ips','log_level'];
var savedKeys = data.keys || [];
var needsRestart = savedKeys.some(function(k) { return systemFields.indexOf(k) >= 0; });
showResult(saveResult, true, needsRestart ? 'Saved — restart container to apply system changes' : 'Saved');
} else {
showResult(saveResult, false, data.detail || 'Save failed');
}
@ -298,6 +347,32 @@
testBtn.textContent = 'Test Connection';
}
});
// Check for Updates
var updBtn = document.getElementById('check-updates-btn');
var updResult = document.getElementById('update-result');
updBtn.addEventListener('click', async function () {
updBtn.disabled = true;
updBtn.textContent = 'Checking…';
updResult.style.display = 'none';
try {
var resp = await fetch('/api/v1/updates/check');
var data = await resp.json();
if (data.update_available) {
showResult(updResult, false, 'Update available: v' + data.latest + ' (current: v' + data.current + ')');
} else if (data.latest) {
showResult(updResult, true, 'Up to date (v' + data.current + ')');
} else {
var msg = data.message || ('v' + data.current + ' — no releases found');
showResult(updResult, true, msg);
}
} catch (e) {
showResult(updResult, false, 'Network error');
} finally {
updBtn.disabled = false;
updBtn.textContent = 'Check for Updates';
}
});
}());
</script>
{% endblock %}