feat(webinstaller): plain-English drive picker on step 2
Replace the numeric "score N" pill with a Recommended badge on the auto-selected drive plus size/type/health chips. The score itself stays as the sort key, users just never see the raw number. Why: Robert's 2026-04-14 wizard UX direction — less jargon, explain Fachbegriffs, recommend defaults. A bare "score 35" gave users no reason why one drive was picked. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
70001f54fd
commit
3b61931936
4 changed files with 80 additions and 12 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
from drives import (
|
from drives import (
|
||||||
|
get_drive_type_label,
|
||||||
get_drive_type_score,
|
get_drive_type_score,
|
||||||
get_size_score,
|
get_size_score,
|
||||||
parse_lsblk_output,
|
parse_lsblk_output,
|
||||||
|
|
@ -61,12 +62,36 @@ def test_score_device_sums_type_and_size(monkeypatch):
|
||||||
def test_parse_lsblk_drops_loop_and_rom(monkeypatch):
|
def test_parse_lsblk_drops_loop_and_rom(monkeypatch):
|
||||||
import drives
|
import drives
|
||||||
|
|
||||||
monkeypatch.setattr(drives, "get_drive_health", lambda _: 10)
|
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
||||||
output = "loop0 2.5G loop\nsr0 1024M rom\nsda 500G disk\nnvme0n1 1T disk\n"
|
output = "loop0 2.5G loop\nsr0 1024M rom\nsda 500G disk\nnvme0n1 1T disk\n"
|
||||||
devices = parse_lsblk_output(output)
|
devices = parse_lsblk_output(output)
|
||||||
names = [d["name"] for d in devices]
|
names = [d["name"] for d in devices]
|
||||||
assert names == ["/dev/nvme0n1", "/dev/sda"]
|
assert names == ["/dev/nvme0n1", "/dev/sda"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_lsblk_attaches_human_labels(monkeypatch):
|
||||||
|
import drives
|
||||||
|
|
||||||
|
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
||||||
|
output = "nvme0n1 1T disk\n"
|
||||||
|
[dev] = parse_lsblk_output(output)
|
||||||
|
assert dev["type_label"] == "NVMe"
|
||||||
|
assert dev["health_label"] == "Healthy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_lsblk_surfaces_smart_warning(monkeypatch):
|
||||||
|
import drives
|
||||||
|
|
||||||
|
monkeypatch.setattr(drives, "_smart_status", lambda _: "failed")
|
||||||
|
[dev] = parse_lsblk_output("sda 500G disk\n")
|
||||||
|
assert dev["health_label"] == "SMART warning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_drive_type_label_nvme_ssd_hdd():
|
||||||
|
assert get_drive_type_label("/dev/nvme0n1") == "NVMe"
|
||||||
|
assert get_drive_type_label("/dev/ssd0") == "SSD"
|
||||||
|
assert get_drive_type_label("/dev/sda") == "HDD"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_lsblk_handles_empty_output():
|
def test_parse_lsblk_handles_empty_output():
|
||||||
assert parse_lsblk_output("") == []
|
assert parse_lsblk_output("") == []
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
def get_drive_health(device):
|
def _smart_status(device):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["smartctl", "-H", device],
|
["smartctl", "-H", device],
|
||||||
|
|
@ -9,13 +9,25 @@ def get_drive_health(device):
|
||||||
)
|
)
|
||||||
output = result.stdout.decode()
|
output = result.stdout.decode()
|
||||||
if "PASSED" in output:
|
if "PASSED" in output:
|
||||||
return 10
|
return "passed"
|
||||||
elif "FAILED" in output:
|
elif "FAILED" in output:
|
||||||
return 0
|
return "failed"
|
||||||
return 5
|
return "unknown"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error checking SMART status for {device}: {e}")
|
print(f"Error checking SMART status for {device}: {e}")
|
||||||
return 5
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
_HEALTH_SCORE = {"passed": 10, "failed": 0, "unknown": 5}
|
||||||
|
_HEALTH_LABEL = {
|
||||||
|
"passed": "Healthy",
|
||||||
|
"failed": "SMART warning",
|
||||||
|
"unknown": "Status unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_drive_health(device):
|
||||||
|
return _HEALTH_SCORE[_smart_status(device)]
|
||||||
|
|
||||||
|
|
||||||
def get_drive_type_score(device):
|
def get_drive_type_score(device):
|
||||||
|
|
@ -27,6 +39,15 @@ def get_drive_type_score(device):
|
||||||
return 5
|
return 5
|
||||||
|
|
||||||
|
|
||||||
|
def get_drive_type_label(device):
|
||||||
|
name = device.lower()
|
||||||
|
if "nvme" in name:
|
||||||
|
return "NVMe"
|
||||||
|
if "ssd" in name:
|
||||||
|
return "SSD"
|
||||||
|
return "HDD"
|
||||||
|
|
||||||
|
|
||||||
def parse_size_gb(size_str):
|
def parse_size_gb(size_str):
|
||||||
size_str = size_str.strip().upper().replace(",", ".")
|
size_str = size_str.strip().upper().replace(",", ".")
|
||||||
if not size_str:
|
if not size_str:
|
||||||
|
|
@ -71,11 +92,20 @@ def parse_lsblk_output(output):
|
||||||
if dev_type != "disk":
|
if dev_type != "disk":
|
||||||
continue
|
continue
|
||||||
device = f"/dev/{name}"
|
device = f"/dev/{name}"
|
||||||
|
size_gb = parse_size_gb(size)
|
||||||
|
status = _smart_status(device)
|
||||||
|
score = (
|
||||||
|
get_drive_type_score(device)
|
||||||
|
+ _HEALTH_SCORE[status]
|
||||||
|
+ get_size_score(size_gb)
|
||||||
|
)
|
||||||
devices.append(
|
devices.append(
|
||||||
{
|
{
|
||||||
"name": device,
|
"name": device,
|
||||||
"size": size,
|
"size": size,
|
||||||
"score": score_device(device, parse_size_gb(size)),
|
"type_label": get_drive_type_label(device),
|
||||||
|
"health_label": _HEALTH_LABEL[status],
|
||||||
|
"score": score,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
devices.sort(key=lambda d: d["score"], reverse=True)
|
devices.sort(key=lambda d: d["score"], reverse=True)
|
||||||
|
|
|
||||||
|
|
@ -217,13 +217,22 @@ select:focus {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--fg-muted);
|
color: var(--fg-muted);
|
||||||
}
|
}
|
||||||
.drive .score {
|
.drive .chip {
|
||||||
background: var(--bg-subtle);
|
background: var(--bg-subtle);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
.drive .badge-recommended {
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, var(--bg-card));
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Summary table (overview) ───────────────────────────────── */
|
/* ── Summary table (overview) ───────────────────────────────── */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Boot drive</h1>
|
<h1>Boot drive</h1>
|
||||||
<p class="lede">Pick the disk Furtka will install onto. The highest-scored drive is recommended.</p>
|
<p class="lede">Pick the disk Furtka will install onto. We pre-select the drive that looks fastest and healthiest.</p>
|
||||||
|
|
||||||
{% if not drives %}
|
{% if not drives %}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
|
|
@ -20,9 +20,13 @@
|
||||||
<input type="radio" name="boot_drive" value="{{ d.name }}"
|
<input type="radio" name="boot_drive" value="{{ d.name }}"
|
||||||
{% if (selected and d.name == selected) or (not selected and loop.first) %}checked{% endif %} />
|
{% if (selected and d.name == selected) or (not selected and loop.first) %}checked{% endif %} />
|
||||||
<span class="name">{{ d.name }}</span>
|
<span class="name">{{ d.name }}</span>
|
||||||
|
{% if not selected and loop.first %}
|
||||||
|
<span class="badge-recommended">Recommended</span>
|
||||||
|
{% endif %}
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
<span>{{ d.size }}</span>
|
<span class="chip">{{ d.size }}</span>
|
||||||
<span class="score">score {{ d.score }}</span>
|
<span class="chip">{{ d.type_label }}</span>
|
||||||
|
<span class="chip">{{ d.health_label }}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue