feat(installer): friendlier wizard, retry after a failed install, no boot stall on multi-NIC boards

- error state gets a 'Try again' button (settings are kept) plus links back
  to the disk / settings pages; the page reloads into it instead of only
  recolouring the bar
- names are lowercased instead of rejected; system account names (root,
  caddy, docker, …) are refused with a clear message
- plain wording: 'Server name' with a live http://<name>.local preview,
  'Pick a disk' with an explicit erase warning, a readable confirm table
  (language label, disk size/type) and a Back button
- the password hint says it is also the SSH / console password; the
  language hint says the wizard itself is English
- wizard pages redirect to the progress page while an install is running
- progress page: aria-live phase, aria-valuenow updates, 'lost connection'
  note, 'fine to close this page'
- .local fallback on the reboot page: router device list, IP may change
- live console banner says a network cable with internet is required
- target gets a systemd-networkd-wait-online --any drop-in: boards with a
  second unplugged NIC (or Ethernet + Wi-Fi) waited up to 2 min at boot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-09-28 13:21:55 +02:00
parent 9f9e627678
commit 7151d411c7
8 changed files with 207 additions and 58 deletions

View file

@ -30,6 +30,8 @@ new=$(
fi fi
echo echo
echo " Then follow the wizard to install Furtka on this machine." echo " Then follow the wizard to install Furtka on this machine."
echo " The install downloads packages, so this machine needs a network"
echo " cable with internet access. Wi-Fi is not set up automatically."
echo echo
echo " Need a shell? Log in as root (no password)." echo " Need a shell? Log in as root (no password)."
echo echo

View file

@ -331,3 +331,62 @@ def test_step2_rejects_disk_not_offered(monkeypatch):
resp = client.post("/install/step2", data={"boot_drive": "/dev/sda"}) resp = client.post("/install/step2", data={"boot_drive": "/dev/sda"})
assert resp.status_code == 302 assert resp.status_code == 302
assert app_module.settings["boot_drive"] == "/dev/sda" assert app_module.settings["boot_drive"] == "/dev/sda"
def test_validate_step1_lowercases_names():
errors, values = validate_step1(
{
"hostname": "Furtka",
"username": "Daniel",
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "de",
}
)
assert errors == []
assert values["hostname"] == "furtka"
assert values["username"] == "daniel"
def test_validate_step1_rejects_reserved_usernames():
for name in ("root", "caddy", "docker", "nobody"):
errors, _ = validate_step1(
{
"hostname": "furtka",
"username": name,
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "en",
}
)
assert len(errors) == 1 and "reserved" in errors[0], name
def test_post_install_sets_wait_online_any(monkeypatch):
import base64
import app as app_module
cmds = app_module._post_install_commands("h", "u", "pw12345678")
hits = [c for c in cmds if "systemd-networkd-wait-online.service.d" in c]
assert len(hits) == 1
payload = hits[0].split("printf %s ")[1].split(" |")[0]
assert "--any" in base64.b64decode(payload).decode()
def test_wizard_pages_redirect_to_progress_while_installing(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=None))
client = app_module.app.test_client()
for path in ("/install/step1", "/install/step2", "/install/overview"):
resp = client.get(path)
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/install/log")
def test_error_page_offers_retry(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
(tmp_path / "install.log").write_text("Traceback (most recent call last)\nboom\n")
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "Try again" in html
assert "next-steps" not in html

View file

@ -78,6 +78,35 @@ settings = {
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
# Accounts that already exist on the installed system (base + the packages
# we install). Picking one of these either fails user creation mid-install
# (root) or makes the admin collide with a service account (caddy, docker…).
RESERVED_USERNAMES = frozenset(
{
"root",
"bin",
"daemon",
"mail",
"ftp",
"http",
"nobody",
"dbus",
"git",
"uuidd",
"avahi",
"caddy",
"docker",
"polkitd",
"rtkit",
"systemd-coredump",
"systemd-journal-remote",
"systemd-network",
"systemd-oom",
"systemd-resolve",
"systemd-timesync",
}
)
# Ordered phase markers for the install progress bar. Each tuple is # Ordered phase markers for the install progress bar. Each tuple is
# (substring to search for in the archinstall log, progress percent when # (substring to search for in the archinstall log, progress percent when
# reached, user-facing label). Pick the furthest phase whose marker is # reached, user-facing label). Pick the furthest phase whose marker is
@ -148,18 +177,26 @@ def parse_install_progress(log, exit_code=None):
def validate_step1(form): def validate_step1(form):
errors = [] errors = []
# Lowercase instead of rejecting "Furtka" / "Daniel" — Linux needs
# lowercase, people type names the way they write them.
values = { values = {
"hostname": form.get("hostname", "").strip(), "hostname": form.get("hostname", "").strip().lower(),
"username": form.get("username", "").strip(), "username": form.get("username", "").strip().lower(),
"password": form.get("password", ""), "password": form.get("password", ""),
"language": form.get("language", ""), "language": form.get("language", ""),
} }
password2 = form.get("password2", "") password2 = form.get("password2", "")
if not HOSTNAME_RE.match(values["hostname"]): if not HOSTNAME_RE.match(values["hostname"]):
errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).") errors.append(
"The server name can only use letters, digits and hyphens (no spaces, max 63)."
)
if not USERNAME_RE.match(values["username"]): if not USERNAME_RE.match(values["username"]):
errors.append("Username must start with a letter or underscore, lowercase only.") errors.append(
"The username must start with a letter and can only use letters, digits, - and _."
)
elif values["username"] in RESERVED_USERNAMES:
errors.append(f"“{values['username']}” is reserved by the system — please pick another.")
if len(values["password"]) < 8: if len(values["password"]) < 8:
errors.append("Password must be at least 8 characters.") errors.append("Password must be at least 8 characters.")
if values["password"] != password2: if values["password"] != password2:
@ -453,6 +490,16 @@ def _post_install_commands(hostname, admin_username, admin_password):
# Initial status.json so Caddy doesn't 404 before furtka-status fires. # Initial status.json so Caddy doesn't 404 before furtka-status fires.
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")), _write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
nss_sed, nss_sed,
# The installed system inherits the live ISO's networkd config
# (DHCP on every Ethernet + Wi-Fi link) but not its wait-online
# drop-in, so a board with a second, unplugged NIC stalled boot for
# up to 2 minutes before network-online.target. Any one link is
# enough for a home server.
_write_file_cmd(
"/etc/systemd/system/systemd-networkd-wait-online.service.d/any-interface.conf",
"[Service]\nExecStart=\n"
"ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any\n",
),
# Resource manager bootstrap: extract tarball → versions/<ver>/, # Resource manager bootstrap: extract tarball → versions/<ver>/,
# symlink current, install wrapper, systemctl-link unit files. # symlink current, install wrapper, systemctl-link unit files.
*_resource_manager_commands(), *_resource_manager_commands(),
@ -585,6 +632,16 @@ def spawn_archinstall(config_path, creds_path, log_path):
) )
@app.before_request
def _stay_on_progress_while_installing():
# While archinstall runs, the wizard pages would let a second browser
# (or Back) edit settings that no longer apply — show the progress.
running = _install_proc is not None and _install_proc.poll() is None
if running and request.endpoint in ("install_step_1", "install_step_2", "install_overview"):
return redirect(url_for("install_log_view"))
return None
@app.route("/") @app.route("/")
def home(): def home():
return redirect(url_for("install_step_1")) return redirect(url_for("install_step_1"))
@ -609,6 +666,11 @@ def install_step_1():
) )
# Size/type of the disk picked in step 2, for the confirm page (re-probing
# would re-run smartctl on every disk).
_selected_drive: dict = {}
@app.route("/install/step2", methods=["GET", "POST"]) @app.route("/install/step2", methods=["GET", "POST"])
def install_step_2(): def install_step_2():
drives = list_scored_devices() drives = list_scored_devices()
@ -616,8 +678,11 @@ def install_step_2():
if request.method == "POST": if request.method == "POST":
boot_drive = request.form.get("boot_drive", "").strip() boot_drive = request.form.get("boot_drive", "").strip()
# Only accept a disk we actually offered — never a free-form path. # Only accept a disk we actually offered — never a free-form path.
if any(d["name"] == boot_drive and d["usable"] for d in drives): picked = next((d for d in drives if d["name"] == boot_drive and d["usable"]), None)
if picked:
settings["boot_drive"] = boot_drive settings["boot_drive"] = boot_drive
_selected_drive.clear()
_selected_drive.update(picked)
return redirect(url_for("install_overview")) return redirect(url_for("install_overview"))
error = "Please pick one of the disks below." error = "Please pick one of the disks below."
return render_template( return render_template(
@ -630,8 +695,14 @@ def install_step_2():
@app.route("/install/overview") @app.route("/install/overview")
def install_overview(): def install_overview():
if not settings["boot_drive"] or not settings["username"]:
return redirect(url_for("install_step_1"))
masked = {**settings, "password": "•" * 8 if settings["password"] else ""} masked = {**settings, "password": "•" * 8 if settings["password"] else ""}
return render_template("install/overview.html", settings=masked) drive = _selected_drive if _selected_drive.get("name") == settings["boot_drive"] else None
language = LANGUAGES.get(settings["language"], {}).get("label", settings["language"])
return render_template(
"install/overview.html", settings=masked, drive=drive, language_label=language
)
# The running archinstall process, so a second click (double-submit, Back + # The running archinstall process, so a second click (double-submit, Back +

View file

@ -18,10 +18,23 @@
</h1> </h1>
<p class="lede" id="install-lede"> <p class="lede" id="install-lede">
{% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk. {% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk.
{% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help. {% elif progress.status == "error" %}{{ progress.phase }}.
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %} {% else %}This takes 5–20 minutes, depending on your internet speed. Don't power off the machine — it's fine to close this page and come back.{% endif %}
</p> </p>
{% if progress.status == "error" %}
<p>Your settings are kept, so you can simply try again. If it fails the same way twice,
open <em>Show details</em> below — the last lines of the log say what went wrong.</p>
<form method="post" action="{{ url_for('install_run') }}"
onsubmit="this.querySelector('button').disabled = true;">
<div class="actions">
<button type="submit" class="btn btn-primary">Try again</button>
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">Pick a different disk</a>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">Change settings</a>
</div>
</form>
{% endif %}
{% if progress.status == "done" %} {% if progress.status == "done" %}
{# The USB-stick step has to be read *before* the click: the machine reboots {# The USB-stick step has to be read *before* the click: the machine reboots
3 s after it, and most BIOSes boot the stick before the disk — so the 3 s after it, and most BIOSes boot the stick before the disk — so the
@ -43,7 +56,7 @@
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}"> <div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}">
<div id="progress-bar" class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div> <div id="progress-bar" class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div>
</div> </div>
<p class="progress-phase"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p> <p class="progress-phase" aria-live="polite"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p>
<details class="log-details"> <details class="log-details">
<summary>Show details</summary> <summary>Show details</summary>
@ -59,17 +72,7 @@
var phaseEl = document.getElementById('progress-phase'); var phaseEl = document.getElementById('progress-phase');
var percentEl = document.getElementById('progress-percent'); var percentEl = document.getElementById('progress-percent');
var logEl = document.getElementById('install-log'); var logEl = document.getElementById('install-log');
var headingEl = document.getElementById('install-heading'); var failures = 0;
var ledeEl = document.getElementById('install-lede');
var HEADINGS = {
done: 'Furtka is ready',
error: 'Installation hit a snag',
};
var LEDES = {
done: 'Installation finished. Remove the USB / eject the installer image, then reboot.',
error: 'Something went wrong. Open the details below and share them so we can help.',
};
function tick() { function tick() {
fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' }) fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' })
@ -77,6 +80,7 @@
.then(function (data) { .then(function (data) {
var p = data.progress; var p = data.progress;
bar.style.width = p.percent + '%'; bar.style.width = p.percent + '%';
bar.parentNode.setAttribute('aria-valuenow', p.percent);
phaseEl.textContent = p.phase; phaseEl.textContent = p.phase;
percentEl.textContent = p.percent; percentEl.textContent = p.percent;
if (logEl.textContent !== data.log) { if (logEl.textContent !== data.log) {
@ -84,21 +88,23 @@
logEl.textContent = data.log || '(waiting for install to start)'; logEl.textContent = data.log || '(waiting for install to start)';
if (atBottom) logEl.scrollTop = logEl.scrollHeight; if (atBottom) logEl.scrollTop = logEl.scrollHeight;
} }
if (p.status === 'done') { if (p.status === 'done' || p.status === 'error') {
// Reload so the server-rendered Done state (with the // Reload so the server-rendered Done / error state (with
// Reboot button) replaces the running-state markup. // its Reboot or Try-again buttons) replaces the
// running-state markup.
window.location.reload(); window.location.reload();
return; return;
} }
if (p.status === 'error') { failures = 0;
bar.classList.add('progress-bar-error');
headingEl.textContent = HEADINGS.error;
ledeEl.textContent = LEDES.error;
return;
}
setTimeout(tick, 3000); setTimeout(tick, 3000);
}) })
.catch(function () { setTimeout(tick, 3000); }); .catch(function () {
failures += 1;
if (failures >= 2) {
phaseEl.textContent = 'Lost connection to the installer — retrying…';
}
setTimeout(tick, 3000);
});
} }
setTimeout(tick, 3000); setTimeout(tick, 3000);
})(); })();

View file

@ -5,28 +5,27 @@
{% block content %} {% block content %}
<h1>Confirm install</h1> <h1>Confirm install</h1>
<p class="lede">Review the settings below. Continuing will <strong>wipe <code>{{ settings.boot_drive }}</code></strong> and install Furtka.</p> <p class="lede">Check the settings below. Continuing will <strong>erase <code>{{ settings.boot_drive }}</code></strong> and install Furtka on it.</p>
<div class="alert alert-warn"> <div class="alert alert-warn" role="alert">
This is destructive. All existing data on the selected boot drive will be lost. Everything currently on this disk will be deleted and can't be recovered.
</div> </div>
<div class="summary"> <div class="summary">
<table> <table>
{% for k, v in settings.items() %} <tr><td>Server name</td><td>{{ settings.hostname }} <span class="hint">(http://{{ settings.hostname }}.local)</span></td></tr>
<tr> <tr><td>Username</td><td>{{ settings.username }}</td></tr>
<td>{{ k.replace('_', ' ') }}</td> <tr><td>Password</td><td>{{ settings.password }}</td></tr>
<td>{{ v }}</td> <tr><td>Language</td><td>{{ language_label }}</td></tr>
</tr> <tr><td>Disk</td><td>{{ settings.boot_drive }}{% if drive %} — {{ drive.size }} {{ drive.type_label }}{% endif %}</td></tr>
{% endfor %}
</table> </table>
</div> </div>
<form method="post" action="{{ url_for('install_run') }}" <form method="post" action="{{ url_for('install_run') }}"
onsubmit="this.querySelector('button').disabled = true; this.querySelector('button').textContent = 'Starting…';"> onsubmit="this.querySelector('button').disabled = true; this.querySelector('button').textContent = 'Starting…';">
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-danger">Install — wipe drive and proceed</button> <button type="submit" class="btn btn-danger">Erase disk and install</button>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Start over</a> <a href="{{ url_for('install_step_2') }}" class="btn btn-link">← Back</a>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -10,5 +10,7 @@
<p class="muted">If the installer does come back anyway, your BIOS is set to boot from USB before the disk. Press the one-time boot menu key at startup (often <kbd>F11</kbd>, <kbd>F12</kbd>, or <kbd>Esc</kbd> — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.</p> <p class="muted">If the installer does come back anyway, your BIOS is set to boot from USB before the disk. Press the one-time boot menu key at startup (often <kbd>F11</kbd>, <kbd>F12</kbd>, or <kbd>Esc</kbd> — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.</p>
<p>When the machine comes back up (~1 minute), open Furtka in your browser:</p> <p>When the machine comes back up (~1 minute), open Furtka in your browser:</p>
<p><a href="http://{{ hostname }}.local" class="btn btn-primary">http://{{ hostname }}.local</a></p> <p><a href="http://{{ hostname }}.local" class="btn btn-primary">http://{{ hostname }}.local</a></p>
<p class="muted">If that doesn't resolve, your network may not support mDNS — use the IP address shown on the machine's console instead.</p> <p class="muted">If that address doesn't open (some Android phones and older Windows PCs can't look up <code>.local</code> names),
use the IP address instead: it's shown on the machine's screen, or look for a device called
<strong>{{ hostname }}</strong> in your router's list of connected devices. The address may differ from the one the installer used.</p>
{% endblock %} {% endblock %}

View file

@ -4,11 +4,11 @@
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %} {% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
{% block content %} {% block content %}
<h1>Account &amp; hostname</h1> <h1>Name &amp; account</h1>
<p class="lede">Set the server name and your administrator account.</p> <p class="lede">Set the server name and your administrator account.</p>
{% if errors %} {% if errors %}
<div class="alert alert-error"> <div class="alert alert-error" role="alert">
<strong>Please fix the following:</strong> <strong>Please fix the following:</strong>
<ul> <ul>
{% for e in errors %}<li>{{ e }}</li>{% endfor %} {% for e in errors %}<li>{{ e }}</li>{% endfor %}
@ -18,19 +18,19 @@
<form method="post" action="{{ url_for('install_step_1') }}"> <form method="post" action="{{ url_for('install_step_1') }}">
<div class="field"> <div class="field">
<label for="hostname">Hostname</label> <label for="hostname">Server name</label>
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" /> <input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="hostname-hint" />
<span class="hint">The name your server shows up as on your home network. Lowercase letters, numbers, and dashes only.</span> <span class="hint" id="hostname-hint">You'll open your server at <strong>http://<span id="hostname-preview">{{ values.hostname or "furtka" }}</span>.local</strong>. Letters, numbers and dashes only.</span>
</div> </div>
<div class="field"> <div class="field">
<label for="username">Admin username</label> <label for="username">Admin username</label>
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" /> <input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" autocapitalize="off" spellcheck="false" aria-describedby="username-hint" />
<span class="hint">Your login name on the server. You'll use this to sign in after installation.</span> <span class="hint" id="username-hint">Your login name on the server. You'll use this to sign in after installation.</span>
</div> </div>
<div class="field"> <div class="field">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="new-password" /> <input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" aria-describedby="password-hint" />
<span class="hint">At least 8 characters.</span> <span class="hint" id="password-hint">At least 8 characters. This one password is used for the web login and for the server itself (SSH / console), so pick a good one.</span>
</div> </div>
<div class="field"> <div class="field">
<label for="password2">Confirm password</label> <label for="password2">Confirm password</label>
@ -38,15 +38,25 @@
</div> </div>
<div class="field"> <div class="field">
<label for="language">Language</label> <label for="language">Language</label>
<select id="language" name="language"> <select id="language" name="language" aria-describedby="language-hint">
{% for code, lang in languages.items() %} {% for code, lang in languages.items() %}
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option> <option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
<span class="hint" id="language-hint">Language and keyboard layout of the installed server. The Furtka pages themselves are in English for now.</span>
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary">Next — pick boot drive</button> <button type="submit" class="btn btn-primary">Next — pick a disk</button>
</div> </div>
</form> </form>
<script>
(function () {
var input = document.getElementById('hostname');
var preview = document.getElementById('hostname-preview');
input.addEventListener('input', function () {
preview.textContent = input.value.trim().toLowerCase() || 'furtka';
});
})();
</script>
{% endblock %} {% endblock %}

View file

@ -1,21 +1,21 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Step 2 — Boot drive · Furtka Installer{% endblock %} {% block title %}Step 2 — Disk · Furtka Installer{% endblock %}
{% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %} {% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %}
{% block content %} {% block content %}
<h1>Boot drive</h1> <h1>Pick a disk</h1>
<p class="lede">Pick the disk Furtka will install onto. We pre-select the drive that looks fastest and healthiest.</p> <p class="lede">Choose the disk Furtka will be installed on. <strong>Everything on it will be erased.</strong> We pre-select the one that looks fastest and healthiest.</p>
{% set usable = drives | selectattr("usable") | list %} {% set usable = drives | selectattr("usable") | list %}
{% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %} {% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %}
{% if error %} {% if error %}
<div class="alert alert-error">{{ error }}</div> <div class="alert alert-error" role="alert">{{ error }}</div>
{% endif %} {% endif %}
{% if not usable %} {% if not usable %}
<div class="alert alert-error"> <div class="alert alert-error" role="alert">
{% if drives %} {% if drives %}
None of the disks in this computer is big enough for Furtka (it needs at least 16 GB). None of the disks in this computer is big enough for Furtka (it needs at least 16 GB).
{% else %} {% else %}