Substantial feature + reliability sweep. Each version below was developed,
tested live against the maple/TrueNAS deployment, and Codex-reviewed
before bundling.
1.0.0-13 — asyncssh proc.kill() doesn't actually kill the remote process
(sshd ignores SSH signal-channel requests by default), so a cancel of a
long-running badblocks left the remote process running and proc.wait()
hanging — pinning the asyncio.Semaphore slot forever.
* Wrap long-lived commands in `sh -c 'echo PID:$$; exec <cmd>'` to
capture the remote PID; store in burnin._remote_pids[job_id].
* burnin._kill_remote_process(job_id) opens a fresh SSH session and
issues `kill -9 <pid>` — sshd honours that.
* Bound proc.wait() with asyncio.wait_for(timeout=15).
* burnin._active_tasks tracks every _run_job task so cancel_job and
check_stuck_jobs can actually cancel the asyncio task (was DB-only
before). Also fixes the documented asyncio.create_task GC gotcha
(weak refs only).
* _run_job finalizer reads current state and skips the write if state
!= 'running' so cancelled/unknown aren't clobbered.
1.0.0-14 — poller._upsert_drive ON CONFLICT only refreshed temperature/
health/poll timestamps; devname/serial/model/size_bytes were stuck at
first-INSERT values forever. After kernel SCSI re-enumeration two
drives could both show as `sda`. Fixed by updating all six fields.
Also added 7-day stale filter to _DRIVES_QUERY so removed drives drop
off the dashboard while audit/burnin_jobs FKs stay intact.
1.0.0-15/-16 — pool-membership lock.
* ssh_client.get_pool_membership() runs `zpool list -vHP` and parses
the flattened TrueNAS output (container vdevs + their device children
both appear at depth 1; section markers cache/log/spare/special/dedup
switch the role).
* ssh_client.get_zfs_member_drives() runs `lsblk -no NAME,FSTYPE -l`
to detect drives carrying ZFS labels not in any active pool — they
get pool_name='(exported)', pool_role='exported'.
* Three idempotent ALTER TABLE migrations on drives:
pool_name/pool_role/pool_seen_at.
* burnin.start_job raises PoolMemberError if pool_name IS NOT NULL and
the drive isn't in burnin._unlock_grants. Routes layer maps to 409
with structured detail {pool_name, pool_role, pool_locked: true} so
the frontend can render an unlock affordance.
* POST /api/v1/drives/{id}/unlock accepts {confirm_token, operator,
reason}. Token is the pool name for active pools, "DESTROY BOOT POOL"
for boot-pool, "DESTROY EXPORTED POOL" for exported. Reason >= 5
chars. TTL = UNLOCK_TTL_SECONDS = 600. Audit event types:
pool_drive_unlocked / boot_pool_drive_unlocked /
exported_pool_drive_unlocked.
* Grants are in-memory only — container restart wipes them.
* UI: lock icon (yellow/red/orange), pool pill, conditional Unlock vs
Burn-In button. modal_unlock.html with type-to-confirm field.
Live unlock countdown via tickUnlockCountdowns() in app.js.
* Daily report: red banner listing every unlock event from the last
24h, with operator + reason + timestamp.
1.0.0-17 — Codex review fail-open + XSS + structured-error fixes.
* ssh_client.get_pool_membership / get_zfs_member_drives now return
None on failure (vs {} for 'definitely empty'). poller passes
update_pool=False to _upsert_drive on detection failure, preserving
existing pool columns instead of clearing them. Without this fix a
1-second SSH blip silently unlocked every drive.
* mailer._build_unlock_banner_html escapes every interpolated field
via html.escape() (was '<' only). Time filter switched to
julianday() — string >= against datetime('now', '-1 day') compared
formats with different separators ('T' vs ' ') and timezone
suffixes, causing subtle off-by-N-hour inclusion.
* app.js submitStart/submitBatchStart now detect the structured
pool_locked 409 detail and auto-open the unlock modal for the
offending drive (was [object Object] in toast).
1.0.0-18 — Codex grant-binding + commit-ordering fixes.
* Unlock grants bound to the (pool_name, pool_role) observed at unlock
time. _UnlockGrant dataclass; _is_unlocked and unlock_expiry
invalidate the grant if the live row's pool identity has changed.
Prevents an 'exported' unlock from carrying over when the drive
turns out to be in active 'tank' or 'boot-pool'.
* grant_pool_unlock now writes to _unlock_grants only AFTER db.commit()
succeeds — previously a failed audit insert left an unaudited grant
armed.
1.0.0-19 — Codex race + cancellation classification + test scaffold.
* Partial unique index uniq_active_burnin_per_drive ON burnin_jobs
(drive_id) WHERE state IN ('queued','running'). INSERT now wraps in
try/except aiosqlite.IntegrityError -> ValueError so the read-then-
insert race in start_job can't produce two queued rows for the same
drive.
* _run_job tracks was_cancelled flag; on bare task.cancel() (shutdown,
future code paths) where DB state is still 'running', finalizer
writes 'unknown' instead of mis-classifying as 'failed'.
* tests/ stdlib unittest scaffold:
- test_pool_parser.py (21 tests): mirror/raidz/draid container vdevs,
single-disk depth-1, plural section markers, partition stripping,
sdaa-style names, multi-pool, role reset between pools.
- test_unlock_flow.py (18 tests): token validation per pool kind,
identity-binding invalidation, TTL expiry, audit-commit-then-arm
ordering, unique-active-burnin partial index.
Run via `python -m unittest discover tests/`. No new dependencies.
1.0.0-20 — Spearfoot-inspired badblocks tunables.
* surface_validate_block_size (-b, default 4096), surface_validate_
block_buffer (-c, default 64), surface_validate_passes (-p, default
1) exposed in Settings UI; persist via settings_store.json.
Validation: block size must be a power of 2 between 512 and
1048576. Defaults preserve existing behaviour. Bumping to 8192/64/1
roughly halves runtime on multi-TB HDDs at ~2x RAM cost.
1.0.0-21 — SMART overall-health column actually populated.
* /api/v2.0/disk doesn't expose smart_health, so every drive defaulted
to UNKNOWN forever (only burn-in stages ever wrote a real value).
* ssh_client.get_smart_health_map([devnames]) runs `smartctl -H` for
all drives in a single SSH session, deterministically delimited with
@@devname@@ ... @@END@@ markers. Returns {devname: PASSED|FAILED|
UNKNOWN} or None on SSH failure.
* poller calls it every 5th cycle (~1 min at default 12s interval),
caches in _state['smart_health_cache'] so transient failures preserve
the previous values.
* Dashboard CSS: col-smart min-width 150 -> 95, horizontal padding 14
-> 6 so Short/Long SMART columns fit comfortably on a 13-inch
display.
* 5 additional parser tests (44 total, all passing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
497 lines
22 KiB
HTML
497 lines
22 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 class="sf-row">
|
||
<label class="sf-label" for="surface_validate_block_size">Badblocks Block Size (bytes)</label>
|
||
<input class="sf-input sf-input-xs" id="surface_validate_block_size"
|
||
name="surface_validate_block_size" type="number" min="512" max="1048576" step="512"
|
||
value="{{ editable.surface_validate_block_size }}">
|
||
<span class="sf-hint">badblocks -b. 4096 (default) is conservative; 8192 is faster on multi-TB HDDs (~2x RAM, ~half the runtime). Power of 2.</span>
|
||
</div>
|
||
|
||
<div class="sf-row">
|
||
<label class="sf-label" for="surface_validate_block_buffer">Badblocks Block Buffer</label>
|
||
<input class="sf-input sf-input-xs" id="surface_validate_block_buffer"
|
||
name="surface_validate_block_buffer" type="number" min="1" max="4096"
|
||
value="{{ editable.surface_validate_block_buffer }}">
|
||
<span class="sf-hint">badblocks -c. 64 (default) matches the upstream tool. Buffer = block_size × this many blocks per IO.</span>
|
||
</div>
|
||
|
||
<div class="sf-row">
|
||
<label class="sf-label" for="surface_validate_passes">Badblocks Passes</label>
|
||
<input class="sf-input sf-input-xs" id="surface_validate_passes"
|
||
name="surface_validate_passes" type="number" min="0" max="16"
|
||
value="{{ editable.surface_validate_passes }}">
|
||
<span class="sf-hint">badblocks -p. 1 = repeat until one consecutive clean scan (default). 2-3 for paranoid burn-in that re-confirms after errors.</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>⚠ 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 %}
|