nas-burnin/claude-sandbox/truenas-burnin/app/templates/settings.html
echoparkbaby 3e0000528f TrueNAS Burn-In Dashboard v0.9.0 — Live mode, thermal monitoring, adaptive concurrency
Go live against real TrueNAS SCALE 25.10:
- Remove mock-truenas dependency; mount SSH key as Docker secret
- Filter expired disk records from /api/v2.0/disk (expiretime field)
- Route all SMART operations through SSH (SCALE 25.10 removed REST smart/test endpoint)
- Poll drive temperatures via POST /api/v2.0/disk/temperatures (SCALE-specific)
- Store raw smartctl output in smart_tests.raw_output for proof of test execution
- Fix percent-remaining=0 false jump to 100% on test start
- Fix terminal WebSocket: add mounted key file fallback (/run/secrets/ssh_key)
- Fix WebSocket support: uvicorn → uvicorn[standard] (installs websockets)

HBA/system sensor temps on dashboard:
- SSH to TrueNAS and run sensors -j each poll cycle
- Parse coretemp (CPU package) and pch_* (PCH/chipset — storage I/O proxy)
- Render as compact chips in stats bar, color-coded green/yellow/red
- Live updates via new SSE system-sensors event every 12s

Adaptive concurrency signal:
- Thermal pressure indicator in stats bar: hidden when OK, WARM/HOT when running
  burn-in drives hit temp_warn_c / temp_crit_c thresholds
- Thermal gate in burn-in queue: jobs wait up to 3 min before acquiring semaphore
  slot if running drives are already at warning temp; times out and proceeds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 06:33:36 -05:00

473 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "layout.html" %}
{% block title %}TrueNAS Burn-In — Settings{% endblock %}
{% block content %}
<div class="page-toolbar">
<h1 class="page-title">Settings</h1>
<div class="toolbar-right">
<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> are saved but need a container restart to fully apply.
</p>
<form id="settings-form" autocomplete="off">
<div class="settings-two-col">
<!-- LEFT: Email / SMTP + Webhook stacked -->
<div class="settings-left-col">
<div class="settings-card">
<div class="settings-card-header">
<span class="settings-card-title">Email (SMTP)</span>
{% if smtp_enabled %}
<span class="chip chip-passed" style="font-size:10px">Enabled</span>
{% else %}
<span class="chip chip-unknown" style="font-size:10px">Disabled — set Host to enable</span>
{% endif %}
</div>
<!-- Compact horizontal field grid -->
<div class="sf-fields">
<!-- Test connection row — full width -->
<div class="sf-full sf-row-test" style="margin-bottom:4px">
<button type="button" id="test-smtp-btn" class="btn-secondary">Test Connection</button>
<span id="smtp-test-result" class="settings-test-result" style="display:none"></span>
</div>
<label for="smtp_host">Host</label>
<input class="sf-input" id="smtp_host" name="smtp_host" type="text"
value="{{ editable.smtp_host }}" placeholder="smtp.example.com">
<label for="smtp_ssl_mode">Mode</label>
<div class="sf-inline-group">
<select class="sf-select" id="smtp_ssl_mode" name="smtp_ssl_mode">
<option value="starttls" {% if editable.smtp_ssl_mode == 'starttls' %}selected{% endif %}>STARTTLS (587)</option>
<option value="ssl" {% if editable.smtp_ssl_mode == 'ssl' %}selected{% endif %}>SSL / TLS (465)</option>
<option value="plain" {% if editable.smtp_ssl_mode == 'plain' %}selected{% endif %}>Plain (25)</option>
</select>
<span class="sf-label-sm">Timeout</span>
<input class="sf-input sf-input-xs" id="smtp_timeout" name="smtp_timeout"
type="number" min="5" max="300" value="{{ editable.smtp_timeout }}" style="width:52px">
</div>
<label for="smtp_user">Username</label>
<input class="sf-input" id="smtp_user" name="smtp_user" type="text"
value="{{ editable.smtp_user }}" autocomplete="off">
<label for="smtp_password">Password</label>
<input class="sf-input" id="smtp_password" name="smtp_password" type="password"
placeholder="leave blank to keep existing" autocomplete="new-password">
<label for="smtp_from">From</label>
<input class="sf-input" id="smtp_from" name="smtp_from" type="text"
value="{{ editable.smtp_from }}" placeholder="burnin@example.com">
<label for="smtp_to">To</label>
<input class="sf-input" id="smtp_to" name="smtp_to" type="text"
value="{{ editable.smtp_to }}" placeholder="you@example.com">
</div>
</div>
<!-- Webhook -->
<div class="settings-card">
<div class="settings-card-header">
<span class="settings-card-title">Webhook</span>
</div>
<div class="sf-fields">
<label for="webhook_url">URL</label>
<div>
<input class="sf-input" id="webhook_url" name="webhook_url" type="text"
value="{{ editable.webhook_url }}" placeholder="https://ntfy.sh/your-topic">
<span class="sf-hint" style="margin-top:3px">POST JSON on burnin_passed / burnin_failed. ntfy.sh, Slack, Discord, n8n. Leave blank to disable.</span>
</div>
</div>
</div>
<!-- SSH -->
<div class="settings-card">
<div class="settings-card-header">
<span class="settings-card-title">SSH (TrueNAS Direct)</span>
{% if ssh_configured %}
<span class="chip chip-passed" style="font-size:10px">Configured</span>
{% else %}
<span class="chip chip-unknown" style="font-size:10px">Not configured — using REST API / mock</span>
{% endif %}
</div>
<p class="sf-hint" style="margin-bottom:8px">
When configured, burn-in stages run smartctl and badblocks directly on TrueNAS over SSH,
enabling SMART attribute monitoring and real bad-block detection. Leave Host empty to use
the TrueNAS REST API (mock / dev mode).
</p>
<div class="sf-fields">
<div class="sf-full sf-row-test" style="margin-bottom:4px">
<button type="button" id="test-ssh-btn" class="btn-secondary">Test SSH Connection</button>
<span id="ssh-test-result" class="settings-test-result" style="display:none"></span>
</div>
<label for="ssh_host">Host / IP</label>
<input class="sf-input" id="ssh_host" name="ssh_host" type="text"
value="{{ editable.ssh_host }}" placeholder="10.0.0.x (same as TrueNAS IP)">
<label for="ssh_port">Port</label>
<input class="sf-input sf-input-xs" id="ssh_port" name="ssh_port"
type="number" min="1" max="65535" value="{{ editable.ssh_port }}" style="width:70px">
<label for="ssh_user">Username</label>
<input class="sf-input" id="ssh_user" name="ssh_user" type="text"
value="{{ editable.ssh_user }}" placeholder="root">
<label for="ssh_password">Password</label>
<input class="sf-input" id="ssh_password" name="ssh_password" type="password"
placeholder="leave blank to keep existing" autocomplete="new-password">
<label for="ssh_key">Private Key</label>
<div>
<textarea class="sf-input sf-textarea" id="ssh_key" name="ssh_key"
rows="6" placeholder="Paste PEM private key here (-----BEGIN ... KEY-----). Leave blank to keep existing." autocomplete="off"></textarea>
<span class="sf-hint" style="margin-top:3px">
Either password or key auth. Key takes precedence if both are set.
Key is stored securely in <code>/data/settings_overrides.json</code>.
</span>
</div>
</div>
</div>
</div><!-- /left col -->
<!-- RIGHT column: Notifications + Behavior -->
<div class="settings-right-col">
<!-- Notifications -->
<div class="settings-card">
<div class="settings-card-header">
<span class="settings-card-title">Notifications</span>
</div>
<div class="sf-toggle-row">
<div>
<div class="sf-label">Daily Report</div>
<div class="sf-hint">Full drive status email each day</div>
</div>
<label class="toggle">
<input type="checkbox" name="smtp_daily_report_enabled" id="smtp_daily_report_enabled"
{% if editable.smtp_daily_report_enabled %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="sf-row sf-row-inline" id="report-hour-wrap"
{% if not editable.smtp_daily_report_enabled %}style="opacity:.4;pointer-events:none"{% endif %}>
<div>
<label class="sf-label" for="smtp_report_hour">Report Hour (023 local)</label>
<input class="sf-input sf-input-xs" id="smtp_report_hour" name="smtp_report_hour"
type="number" min="0" max="23" value="{{ editable.smtp_report_hour }}">
</div>
</div>
<div class="sf-divider"></div>
<div class="sf-toggle-row">
<div>
<div class="sf-label">Alert on Failure</div>
<div class="sf-hint">Immediate email when a burn-in fails</div>
</div>
<label class="toggle">
<input type="checkbox" name="smtp_alert_on_fail" id="smtp_alert_on_fail"
{% if editable.smtp_alert_on_fail %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="sf-toggle-row">
<div>
<div class="sf-label">Alert on Pass</div>
<div class="sf-hint">Immediate email when a burn-in passes</div>
</div>
<label class="toggle">
<input type="checkbox" name="smtp_alert_on_pass" id="smtp_alert_on_pass"
{% if editable.smtp_alert_on_pass %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<!-- Behavior -->
<div class="settings-card">
<div class="settings-card-header">
<span class="settings-card-title">Burn-In Behavior</span>
</div>
<div class="sf-row">
<label class="sf-label" for="max_parallel_burnins">Max Parallel Burn-Ins</label>
<input class="sf-input sf-input-xs" id="max_parallel_burnins" name="max_parallel_burnins"
type="number" min="1" max="60" value="{{ editable.max_parallel_burnins }}">
<span class="sf-hint">How many jobs can run at the same time</span>
</div>
<div id="parallel-warn" class="sf-inline-warn"
{% if editable.max_parallel_burnins <= 8 %}style="display:none"{% endif %}>
⚠ Running many simultaneous surface scans may saturate your storage controller
and produce unreliable results. Recommended: 24.
</div>
<div class="sf-row">
<label class="sf-label" for="stuck_job_hours">Stuck Job Threshold (hours)</label>
<input class="sf-input sf-input-xs" id="stuck_job_hours" name="stuck_job_hours"
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>
<button type="button" class="btn-secondary" id="cancel-settings-btn">Cancel</button>
<span id="save-result" class="settings-test-result" style="display:none"></span>
</div>
<!-- Restart required banner — shown after saving system settings -->
<div id="restart-banner" style="display:none;margin-top:12px;padding:12px 16px;background:rgba(255,170,0,0.12);border:1px solid var(--yellow);border-radius:8px;color:var(--text-strong)">
<strong>&#9888; Container restart required</strong> — system settings are saved but won't take effect until you restart the app container:
<pre style="margin:8px 0 0;padding:8px 10px;background:var(--bg-card);border-radius:5px;font-size:12px;color:var(--text-strong);user-select:all">docker compose restart app</pre>
<span style="font-size:11px;color:var(--text-muted)">Run this on <strong>maple.local</strong> from <code>~/docker/stacks/truenas-burnin/</code></span>
</div>
</form>
<script>
(function () {
// Dim report-hour when daily report disabled
var dailyCb = document.getElementById('smtp_daily_report_enabled');
var hourWrap = document.getElementById('report-hour-wrap');
if (dailyCb && hourWrap) {
dailyCb.addEventListener('change', function () {
hourWrap.style.opacity = dailyCb.checked ? '' : '0.4';
hourWrap.style.pointerEvents = dailyCb.checked ? '' : 'none';
});
}
function showResult(el, ok, msg) {
el.style.display = 'inline-flex';
el.className = 'settings-test-result ' + (ok ? 'result-ok' : 'result-err');
el.textContent = (ok ? '✓ ' : '✕ ') + msg;
}
function collectForm() {
var form = document.getElementById('settings-form');
var data = {};
for (var i = 0; i < form.elements.length; i++) {
var el = form.elements[i];
if (!el.name || el.type === 'submit' || el.type === 'button') continue;
data[el.name] = el.type === 'checkbox' ? el.checked : el.value;
}
return data;
}
// Save
var form = document.getElementById('settings-form');
var saveBtn = document.getElementById('save-btn');
var saveResult = document.getElementById('save-result');
form.addEventListener('submit', async function (e) {
e.preventDefault();
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
saveResult.style.display = 'none';
try {
var resp = await fetch('/api/v1/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectForm()),
});
var data = await resp.json();
if (resp.ok) {
// 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, 'Saved');
var restartBanner = document.getElementById('restart-banner');
if (restartBanner) restartBanner.style.display = needsRestart ? '' : 'none';
} else {
showResult(saveResult, false, data.detail || 'Save failed');
}
} catch (e) {
showResult(saveResult, false, 'Network error');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Settings';
}
});
// Cancel — reload page to restore saved values
var cancelBtn = document.getElementById('cancel-settings-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', function () {
window.location.reload();
});
}
// Test SMTP
var testBtn = document.getElementById('test-smtp-btn');
var testResult = document.getElementById('smtp-test-result');
testBtn.addEventListener('click', async function () {
testBtn.disabled = true;
testBtn.textContent = 'Testing…';
testResult.style.display = 'none';
try {
var resp = await fetch('/api/v1/settings/test-smtp', { method: 'POST' });
var data = await resp.json();
showResult(testResult, resp.ok, resp.ok ? 'Connection OK' : (data.detail || 'Failed'));
} catch (e) {
showResult(testResult, false, 'Network error');
} finally {
testBtn.disabled = false;
testBtn.textContent = 'Test Connection';
}
});
// Parallel burn-in warning
var parallelInput = document.getElementById('max_parallel_burnins');
var parallelWarn = document.getElementById('parallel-warn');
if (parallelInput && parallelWarn) {
parallelInput.addEventListener('input', function () {
parallelWarn.style.display = parseInt(parallelInput.value, 10) > 8 ? '' : 'none';
});
}
// Test SSH
var sshBtn = document.getElementById('test-ssh-btn');
var sshResult = document.getElementById('ssh-test-result');
if (sshBtn) {
sshBtn.addEventListener('click', async function () {
sshBtn.disabled = true;
sshBtn.textContent = 'Testing…';
sshResult.style.display = 'none';
try {
var resp = await fetch('/api/v1/settings/test-ssh', { method: 'POST' });
var data = await resp.json();
showResult(sshResult, resp.ok, resp.ok ? 'Connection OK' : (data.detail || 'Failed'));
} catch (e) {
showResult(sshResult, false, 'Network error');
} finally {
sshBtn.disabled = false;
sshBtn.textContent = 'Test SSH 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 %}