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>
184 lines
8.1 KiB
HTML
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>
|