SSH (app/ssh_client.py — new):
- asyncssh-based client: start_smart_test, poll_smart_progress, abort_smart_test,
get_smart_attributes, run_badblocks with streaming progress callbacks
- SMART attribute table: monitors attrs 5/10/188/197/198/199 for warn/fail thresholds
- Falls back to REST API / mock simulation when ssh_host is not configured
Burn-in stages updated (burnin.py):
- _stage_smart_test: SSH path polls smartctl -a, stores raw output + parsed attributes
- _stage_surface_validate: SSH path streams badblocks, counts bad blocks vs configurable threshold
- _stage_final_check: SSH path checks smartctl attributes; DB fallback for mock mode
- New DB helpers: _append_stage_log, _update_stage_bad_blocks, _store_smart_attrs,
_store_smart_raw_output
Database (database.py):
- Migrations: burnin_stages.log_text, burnin_stages.bad_blocks,
drives.smart_attrs (JSON), smart_tests.raw_output
Settings (config.py + settings_store.py):
- ssh_host, ssh_port, ssh_user, ssh_password, ssh_key — all runtime-editable
- SSH section in Settings UI with Test SSH Connection button
Webhook (notifier.py):
- Added bad_blocks and timestamp fields to payload per SPEC
Drive reset (routes.py + drives_table.html):
- POST /api/v1/drives/{id}/reset — clears SMART state, smart_attrs; audit logged
- Reset button visible on drives with completed test state (no active burn-in)
Log drawer (app.js):
- Burn-In tab: shows raw stage log_text (SSH output) with bad block highlighting
- SMART tab: shows SMART attribute table with warn/fail colouring + raw smartctl output
Polish:
- Version badge (v1.0.0-6d) in header via Jinja2 global
- Parallel burn-in warning when max_parallel_burnins > 8 in Settings
- Stats page: avg duration by drive size + failure breakdown by stage
- settings.html: SSH section with key textarea, parallel warn div
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
464 lines
20 KiB
HTML
464 lines
20 KiB
HTML
{% 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 (0–23 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: 2–4.
|
||
</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>
|
||
</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, needsRestart ? 'Saved — restart container to apply system changes' : 'Saved');
|
||
} 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 %}
|