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:
parent
c0f9098779
commit
4ab54d7ed8
8 changed files with 224 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,17 +805,30 @@ 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),
|
||||
"app_version": settings.app_version,
|
||||
"poller": ps,
|
||||
**_stale_context(ps),
|
||||
})
|
||||
|
|
@ -832,10 +836,11 @@ async def settings_page(
|
|||
|
||||
@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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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_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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue