Full-stack burn-in orchestration dashboard (Stages 1–6d complete): FastAPI backend, SQLite/WAL, SSE live dashboard, mock TrueNAS server, SMTP/webhook notifications, batch burn-in, settings UI, audit log, stats page, cancel SMART/burn-in, drag-to-reorder stages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
303 lines
12 KiB
HTML
303 lines
12 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">
|
||
<a class="btn-export" href="/docs" target="_blank" rel="noopener">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>.
|
||
</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>
|
||
|
||
</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="16" value="{{ editable.max_parallel_burnins }}">
|
||
<span class="sf-hint">How many jobs can run at the same time</span>
|
||
</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>
|
||
|
||
</div><!-- /right col -->
|
||
</div><!-- /two-col -->
|
||
|
||
<!-- 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>
|
||
|
||
<!-- 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
|
||
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) {
|
||
showResult(saveResult, true, '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';
|
||
}
|
||
});
|
||
}());
|
||
</script>
|
||
{% endblock %}
|