truenas-burnin/app/templates/components/drives_table.html
Brandon Walter 2dff58bd52 Stage 7: SSH architecture, SMART attribute monitoring, drive reset, and polish
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>
2026-02-24 08:09:30 -05:00

184 lines
8.1 KiB
HTML

{%- macro smart_cell(smart) -%}
<div class="smart-cell">
{%- if smart.state == 'running' -%}
<div class="progress-wrap">
<div class="progress-bar">
<div class="progress-fill" style="width:{{ smart.percent or 0 }}%"></div>
</div>
<span class="progress-pct">{{ smart.percent or 0 }}%</span>
</div>
{%- if smart.eta_seconds %}
<div class="eta-text">{{ smart.eta_seconds | format_eta }}</div>
{%- endif %}
{%- elif smart.state == 'passed' -%}
<span class="chip chip-passed">Passed</span>
{%- elif smart.state == 'failed' -%}
<span class="chip chip-failed" title="{{ smart.error_text or '' }}">Failed</span>
{%- elif smart.state == 'aborted' -%}
<span class="chip chip-aborted">Aborted</span>
{%- else -%}
<span class="cell-empty"></span>
{%- endif -%}
</div>
{%- endmacro -%}
{%- macro burnin_cell(bi) -%}
<div class="burnin-cell">
{%- if bi is none -%}
<span class="cell-empty"></span>
{%- elif bi.state == 'queued' -%}
<span class="chip chip-queued">Queued</span>
{%- elif bi.state == 'running' -%}
<div class="progress-wrap">
<div class="progress-bar">
<div class="progress-fill progress-fill-green" style="width:{{ bi.percent or 0 }}%"></div>
</div>
<span class="progress-pct">{{ bi.percent or 0 }}%</span>
</div>
<div class="burnin-meta">
{%- if bi.stage_name %}
<span class="stage-name">{{ bi.stage_name | replace('_', ' ') | title }}</span>
{%- endif %}
{%- if bi.started_at %}
<span class="elapsed-timer" data-started="{{ bi.started_at }}">{{ bi.started_at | format_elapsed }}</span>
{%- endif %}
</div>
{%- elif bi.state == 'passed' -%}
<span class="chip chip-passed">Passed</span>
{%- elif bi.state == 'failed' -%}
<span class="chip chip-failed">Failed{% if bi.stage_name %} ({{ bi.stage_name | replace('_',' ') }}){% endif %}</span>
{%- elif bi.state == 'cancelled' -%}
<span class="chip chip-aborted">Cancelled</span>
{%- elif bi.state == 'unknown' -%}
<span class="chip chip-unknown">Unknown</span>
{%- else -%}
<span class="cell-empty"></span>
{%- endif -%}
</div>
{%- endmacro -%}
<table>
<thead>
<tr>
<th class="col-check">
<input type="checkbox" id="select-all-cb" class="drive-cb" title="Select all idle drives">
</th>
<th class="col-drive">Drive</th>
<th class="col-serial">Serial</th>
<th class="col-size">Size</th>
<th class="col-temp">Temp</th>
<th class="col-health">Health</th>
<th class="col-smart">Short SMART</th>
<th class="col-smart">Long SMART</th>
<th class="col-burnin">Burn-In</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody id="drives-tbody">
{%- if drives %}
{%- for drive in drives %}
{%- set bi_active = drive.burnin and drive.burnin.state in ('queued', 'running') %}
{%- set short_busy = drive.smart_short and drive.smart_short.state == 'running' %}
{%- set long_busy = drive.smart_long and drive.smart_long.state == 'running' %}
{%- set selectable = not bi_active and not short_busy and not long_busy %}
{%- set bi_done = drive.burnin and drive.burnin.state in ('passed', 'failed', 'cancelled', 'unknown') %}
{%- set smart_done = (drive.smart_short and drive.smart_short.state in ('passed','failed','aborted'))
or (drive.smart_long and drive.smart_long.state in ('passed','failed','aborted')) %}
{%- set can_reset = (bi_done or smart_done) and not bi_active and not short_busy and not long_busy %}
<tr data-status="{{ drive.status }}" id="drive-{{ drive.id }}">
<td class="col-check">
{%- if selectable %}
<input type="checkbox" class="drive-checkbox" data-drive-id="{{ drive.id }}">
{%- endif %}
</td>
<td class="col-drive">
<span class="drive-name">{{ drive.devname }}</span>
<span class="drive-model">{{ drive.model or "Unknown" }}</span>
{%- if drive.location %}
<span class="drive-location"
data-drive-id="{{ drive.id }}"
data-field="location"
title="Click to edit location">{{ drive.location }}</span>
{%- else %}
<span class="drive-location drive-location-empty"
data-drive-id="{{ drive.id }}"
data-field="location"
title="Click to set location">+ location</span>
{%- endif %}
</td>
<td class="col-serial mono">{{ drive.serial or "—" }}</td>
<td class="col-size">{{ drive.size_bytes | format_bytes }}</td>
<td class="col-temp">
{%- if drive.temperature_c is not none %}
<span class="temp {{ drive.temperature_c | temp_class }}">{{ drive.temperature_c }}°C</span>
{%- else %}
<span class="cell-empty"></span>
{%- endif %}
</td>
<td class="col-health">
<span class="chip chip-{{ drive.smart_health | lower }}">{{ drive.smart_health }}</span>
</td>
<td class="col-smart">{{ smart_cell(drive.smart_short) }}</td>
<td class="col-smart">{{ smart_cell(drive.smart_long) }}</td>
<td class="col-burnin">{{ burnin_cell(drive.burnin) }}</td>
<td class="col-actions">
<div class="action-group">
{%- if bi_active %}
<!-- Burn-in running/queued: only show cancel -->
<button class="btn-action btn-cancel"
data-job-id="{{ drive.burnin.id }}">✕ Burn-In</button>
{%- else %}
<!-- Short SMART: show cancel if running, else start button -->
{%- if short_busy %}
<button class="btn-action btn-cancel-smart"
data-drive-id="{{ drive.id }}"
data-test-type="short"
title="Cancel Short SMART test">✕ Short</button>
{%- else %}
<button class="btn-action btn-smart-short{% if long_busy %} btn-disabled{% endif %}"
data-drive-id="{{ drive.id }}"
data-test-type="SHORT"
{% if long_busy %}disabled{% endif %}
title="Start Short SMART test">Short</button>
{%- endif %}
<!-- Long SMART: show cancel if running, else start button -->
{%- if long_busy %}
<button class="btn-action btn-cancel-smart"
data-drive-id="{{ drive.id }}"
data-test-type="long"
title="Cancel Long SMART test">✕ Long</button>
{%- else %}
<button class="btn-action btn-smart-long{% if short_busy %} btn-disabled{% endif %}"
data-drive-id="{{ drive.id }}"
data-test-type="LONG"
{% if short_busy %}disabled{% endif %}
title="Start Long SMART test (~several hours)">Long</button>
{%- endif %}
<!-- Burn-In -->
<button class="btn-action btn-start{% if short_busy or long_busy %} btn-disabled{% endif %}"
data-drive-id="{{ drive.id }}"
data-devname="{{ drive.devname }}"
data-serial="{{ drive.serial or '' }}"
data-model="{{ drive.model or 'Unknown' }}"
data-size="{{ drive.size_bytes | format_bytes }}"
data-health="{{ drive.smart_health }}"
{% if short_busy or long_busy %}disabled{% endif %}
title="Start Burn-In">Burn-In</button>
<!-- Reset — clears SMART state so drive can be re-tested from scratch -->
{%- if can_reset %}
<button class="btn-action btn-reset"
data-drive-id="{{ drive.id }}"
title="Reset SMART state — clears test results so drive shows as fresh">Reset</button>
{%- endif %}
{%- endif %}
</div>
</td>
</tr>
{%- endfor %}
{%- else %}
<tr>
<td colspan="10" class="empty-state">No drives found. Waiting for first poll…</td>
</tr>
{%- endif %}
</tbody>
</table>