diff --git a/README.md b/README.md index ecf1fdb..9f2f075 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,13 @@ None of these nail the "your dad can set this up" experience. The installer wiza - [x] Wizard flow spec — see [docs/wizard-flow.md](docs/wizard-flow.md) - [x] Release process + CI — CalVer tags, conventional commits, Forgejo Actions (ruff, pytest, JSON, link checks), `26.0-alpha` tagged - [x] Forgejo runner live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04, Docker + DinD sidecar) — setup captured in [docs/runner-setup.md](docs/runner-setup.md) + [ops/forgejo-runner/](ops/forgejo-runner/) -- [ ] **Publish `26.0-alpha` Forgejo Release** — [releases/new](https://forgejo.sourcegate.online/daniel/furtka/releases/new), paste CHANGELOG section, tick Pre-release *(Daniel, next session)* +- [ ] **Publish `26.0-alpha` Forgejo Release** — deferred. Walking-skeleton ISO boots but doesn't install yet; re-tag once `archinstall` actually completes end-to-end on a VM. - [x] **Walking-skeleton live ISO** — `iso/build.sh` produces a hybrid BIOS/UEFI Arch-based ISO that boots in a Proxmox VM, DHCP's onto the LAN, and serves the Flask webinstaller on `:5000`. Screens 1–3 work end-to-end. Build infra in [`iso/`](iso/). -- [ ] **Drop /dev/loop0 + /dev/sr0 from drive list** — the live ISO's own squashfs and the CD-ROM both show up as install targets. Simple filter in `webinstaller/drives.py`. -- [ ] **Rebrand GRUB menu** — the ISO still boots as "Arch Linux install medium". Cosmetic, fix when we start caring about end-user-facing polish. +- [x] **Drop loop/rom devices from drive list** — `webinstaller/drives.py` now filters by `lsblk` `TYPE=disk`, so the live squashfs and CD-ROM no longer appear as install targets. +- [x] **Rebrand GRUB menu** — `iso/build.sh` rewrites "Arch Linux install medium" → "Furtka Live Installer" across GRUB, syslinux, and systemd-boot configs. +- [x] **S1 account form + overview → `archinstall`** — S1 collects hostname/user/password/language with validation, S2 picks boot drive, overview confirms, `/install/run` writes `user_configuration.json` + `user_credentials.json` (0600) and execs `archinstall --silent`, log page polls output. `FURTKA_DRY_RUN=1` skips the exec for testing. - [ ] **Base OS post-install** — what Furtka actually looks like *after* the wizard writes config + reboots: Caddy + Authentik + app store. Robert's area. -- [ ] Installer wizard screens S4–S8 (user/password, domain, SSL, diagnostic, confirm) + actually invoking `archinstall` on the chosen disk +- [ ] Installer wizard screens S3–S7 — per-device purpose, network, domain, SSL, diagnostic. S5/S6 blocked on managed-gateway DNS infra not yet built. - [ ] `https://proksi.local` via mDNS + local CA (currently only raw-IP HTTP) - [ ] Caddy + Authentik wired into first-boot bootstrap - [ ] Managed gateway infrastructure — `ns1/ns2.furtka.org` + DNS-01 wildcard automation diff --git a/iso/build.sh b/iso/build.sh index e403ac6..fc45ca0 100755 --- a/iso/build.sh +++ b/iso/build.sh @@ -45,6 +45,16 @@ cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh" cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/" +echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)" +# releng ships menu entries labelled "Arch Linux install medium" across three +# bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to +# our brand. Done with sed (not a static overlay) so upstream archiso file +# moves don't silently leave stale Arch labels behind. +find "$PROFILE_WORK/grub" "$PROFILE_WORK/syslinux" "$PROFILE_WORK/efiboot" \ + -type f \( -name "*.cfg" -o -name "*.conf" \) -print0 \ + | xargs -0 sed -i \ + -e 's/Arch Linux install medium/Furtka Live Installer/g' + mkdir -p "$PROFILE_WORK/airootfs/opt/furtka" cp -a "$REPO_ROOT/webinstaller/." "$PROFILE_WORK/airootfs/opt/furtka/" rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__" diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..e1cfa19 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,61 @@ +from app import ( + build_archinstall_config, + build_archinstall_creds, + validate_step1, +) + + +def test_validate_step1_accepts_good_input(): + errors, values = validate_step1( + { + "hostname": "furtka", + "username": "daniel", + "password": "topsecretpw", + "password2": "topsecretpw", + "language": "de", + } + ) + assert errors == [] + assert values == { + "hostname": "furtka", + "username": "daniel", + "password": "topsecretpw", + "language": "de", + } + + +def test_validate_step1_collects_all_errors(): + errors, _ = validate_step1( + { + "hostname": "BAD!", + "username": "1bad", + "password": "short", + "password2": "mismatch", + "language": "xx", + } + ) + assert len(errors) == 5 + + +def test_build_archinstall_config_uses_selected_locale(): + cfg = build_archinstall_config( + { + "hostname": "h", + "username": "u", + "password": "pw12345678", + "language": "pl", + "boot_drive": "/dev/sda", + } + ) + assert cfg["disk_config"]["device"] == "/dev/sda" + assert cfg["hostname"] == "h" + assert cfg["users"][0]["username"] == "u" + assert cfg["locale_config"]["locale"] == "pl_PL.UTF-8" + + +def test_build_archinstall_creds_reuses_password_for_root_and_user(): + creds = build_archinstall_creds( + {"username": "u", "password": "pw12345678"} + ) + assert creds["root_password"] == "pw12345678" + assert creds["users"] == [{"username": "u", "password": "pw12345678"}] diff --git a/tests/test_drives.py b/tests/test_drives.py index 2681719..6882284 100644 --- a/tests/test_drives.py +++ b/tests/test_drives.py @@ -1,6 +1,7 @@ from drives import ( get_drive_type_score, get_size_score, + parse_lsblk_output, parse_size_gb, score_device, ) @@ -55,3 +56,22 @@ def test_score_device_sums_type_and_size(monkeypatch): monkeypatch.setattr(drives, "get_drive_health", lambda _: 10) assert score_device("/dev/nvme0n1", 1024) == 15 + 10 + 10 assert score_device("/dev/sda", 64) == 5 + 10 + 5 + + +def test_parse_lsblk_drops_loop_and_rom(monkeypatch): + import drives + + monkeypatch.setattr(drives, "get_drive_health", lambda _: 10) + output = ( + "loop0 2.5G loop\n" + "sr0 1024M rom\n" + "sda 500G disk\n" + "nvme0n1 1T disk\n" + ) + devices = parse_lsblk_output(output) + names = [d["name"] for d in devices] + assert names == ["/dev/nvme0n1", "/dev/sda"] + + +def test_parse_lsblk_handles_empty_output(): + assert parse_lsblk_output("") == [] diff --git a/webinstaller/app.py b/webinstaller/app.py index a81f045..4e8a589 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -1,46 +1,185 @@ +import json +import os +import re +import subprocess +from pathlib import Path + from drives import list_scored_devices from flask import Flask, redirect, render_template, request, url_for app = Flask(__name__) +LANGUAGES = { + "en": {"locale": "en_US.UTF-8", "label": "English"}, + "de": {"locale": "de_DE.UTF-8", "label": "Deutsch"}, + "pl": {"locale": "pl_PL.UTF-8", "label": "Polski"}, +} + +STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/tmp/furtka")) +INSTALL_LOG = STATE_DIR / "install.log" +CONFIG_PATH = STATE_DIR / "user_configuration.json" +CREDS_PATH = STATE_DIR / "user_credentials.json" + +# Pre-populated with sane defaults so the form has something useful on first +# render. POSTs validate and overwrite. settings = { - # Step 1 "hostname": "furtka", "username": "", "password": "", - "password2": "", - "backend": False, - "backend_adress": "127.0.0.1", "language": "en", - # devices - "boot_drive_uuid": "", + "boot_drive": "", } +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}$") + + +def validate_step1(form): + errors = [] + values = { + "hostname": form.get("hostname", "").strip(), + "username": form.get("username", "").strip(), + "password": form.get("password", ""), + "language": form.get("language", ""), + } + password2 = form.get("password2", "") + + if not HOSTNAME_RE.match(values["hostname"]): + errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).") + if not USERNAME_RE.match(values["username"]): + errors.append("Username must start with a letter or underscore, lowercase only.") + if len(values["password"]) < 8: + errors.append("Password must be at least 8 characters.") + if values["password"] != password2: + errors.append("Passwords do not match.") + if values["language"] not in LANGUAGES: + errors.append("Pick a language.") + return errors, values + + +def build_archinstall_config(s): + return { + "archinstall-language": "English", + "timezone": "Europe/Berlin", + "ntp": True, + "bootloader": "Systemd-boot", + "disk_config": { + "config_type": "use_entire_disk", + "device": s["boot_drive"], + "filesystem": "ext4", + }, + "hostname": s["hostname"], + "kernels": ["linux"], + "packages": ["docker", "docker-compose", "vim", "git", "htop", "curl"], + "profile": {"type": "server"}, + "services": ["docker"], + "network_config": {"type": "iso"}, + "users": [{"username": s["username"], "sudo": True, "groups": ["docker"]}], + "ssh": True, + "audio_config": None, + "locale_config": { + "locale": LANGUAGES[s["language"]]["locale"], + "keyboard_layout": "us", + }, + } + + +def build_archinstall_creds(s): + return { + "root_password": s["password"], + "users": [{"username": s["username"], "password": s["password"]}], + } + + +def write_install_files(s, state_dir): + state_dir.mkdir(parents=True, exist_ok=True) + config_path = state_dir / "user_configuration.json" + creds_path = state_dir / "user_credentials.json" + config_path.write_text(json.dumps(build_archinstall_config(s), indent=2)) + creds_path.write_text(json.dumps(build_archinstall_creds(s), indent=2)) + creds_path.chmod(0o600) + return config_path, creds_path + + +def spawn_archinstall(config_path, creds_path, log_path): + log_fh = open(log_path, "wb") + return subprocess.Popen( + [ + "archinstall", + "--config", str(config_path), + "--creds", str(creds_path), + "--silent", + ], + stdout=log_fh, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + @app.route("/") def home(): - return "Hello World" + return redirect(url_for("install_step_1")) @app.route("/install/step1", methods=["GET", "POST"]) def install_step_1(): + errors = [] if request.method == "POST": - settings["hostname"] = request.form["hostname"] - return redirect(url_for("install_step_2")) - return render_template("install/step1.html") + errors, values = validate_step1(request.form) + if not errors: + settings.update(values) + return redirect(url_for("install_step_2")) + form_values = values + else: + form_values = {k: settings[k] for k in ("hostname", "username", "language")} + return render_template( + "install/step1.html", + values=form_values, + languages=LANGUAGES, + errors=errors, + ) @app.route("/install/step2", methods=["GET", "POST"]) def install_step_2(): if request.method == "POST": - settings["boot_drive_uuid"] = request.form["boot_drive_uuid"] - return redirect(url_for("install_overview")) - return render_template("install/step2.html", drives=list_scored_devices()) + boot_drive = request.form.get("boot_drive", "").strip() + if boot_drive: + settings["boot_drive"] = boot_drive + return redirect(url_for("install_overview")) + return render_template( + "install/step2.html", + drives=list_scored_devices(), + selected=settings.get("boot_drive", ""), + ) @app.route("/install/overview") def install_overview(): - return render_template("install/overview.html", settings=settings) + masked = {**settings, "password": "•" * 8 if settings["password"] else ""} + return render_template("install/overview.html", settings=masked) + + +@app.route("/install/run", methods=["POST"]) +def install_run(): + if not settings["boot_drive"] or not settings["username"] or not settings["password"]: + return redirect(url_for("install_step_1")) + config_path, creds_path = write_install_files(settings, STATE_DIR) + INSTALL_LOG.write_bytes(b"") + if os.environ.get("FURTKA_DRY_RUN") == "1": + INSTALL_LOG.write_text( + f"DRY RUN: would exec archinstall --config {config_path} " + f"--creds {creds_path} --silent\n" + ) + else: + spawn_archinstall(config_path, creds_path, INSTALL_LOG) + return redirect(url_for("install_log_view")) + + +@app.route("/install/log") +def install_log_view(): + log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else "(waiting for install to start)\n" + return render_template("install/log.html", log=log) if __name__ == "__main__": diff --git a/webinstaller/drives.py b/webinstaller/drives.py index e46fa15..6011ad8 100644 --- a/webinstaller/drives.py +++ b/webinstaller/drives.py @@ -54,36 +54,47 @@ def score_device(device, size_gb): return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb) +def parse_lsblk_output(output): + """Parse `lsblk -dn -o NAME,SIZE,TYPE` output into scored device dicts. + + Keeps only TYPE=disk so the live ISO's own squashfs (loop) and the boot + CD-ROM (rom) don't show up as install targets. + """ + devices = [] + for line in output.strip().split("\n"): + if not line: + continue + parts = line.split() + if len(parts) < 3: + continue + name, size, dev_type = parts[0], parts[1], parts[2] + if dev_type != "disk": + continue + device = f"/dev/{name}" + devices.append( + { + "name": device, + "size": size, + "score": score_device(device, parse_size_gb(size)), + } + ) + devices.sort(key=lambda d: d["score"], reverse=True) + return devices + + def list_scored_devices(): """Return [{name, size, score}, ...] for all physical disks, highest score first.""" - devices = [] try: result = subprocess.run( - ["lsblk", "-dn", "-o", "NAME,SIZE"], + ["lsblk", "-dn", "-o", "NAME,SIZE,TYPE"], capture_output=True, text=True, check=True, ) - for line in result.stdout.strip().split("\n"): - if not line: - continue - parts = line.split() - if len(parts) < 2: - continue - name, size = parts[0], parts[1] - device = f"/dev/{name}" - devices.append( - { - "name": device, - "size": size, - "score": score_device(device, parse_size_gb(size)), - } - ) except subprocess.CalledProcessError as e: print(f"Error listing devices: {e}") - - devices.sort(key=lambda d: d["score"], reverse=True) - return devices + return [] + return parse_lsblk_output(result.stdout) def main(): diff --git a/webinstaller/static/style.css b/webinstaller/static/style.css index e69de29..2b5ed81 100644 --- a/webinstaller/static/style.css +++ b/webinstaller/static/style.css @@ -0,0 +1,371 @@ +:root { + --bg: #f7f6f3; + --bg-subtle: #efeee8; + --bg-card: #ffffff; + --fg: #0e0e0f; + --fg-muted: #6b6b6f; + --accent: #c03a28; + --accent-hover: #a0301f; + --border: #e4e3dc; + --danger: #b00020; + --success: #2e7d32; + --font-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, "Noto Sans", sans-serif; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d0d0f; + --bg-subtle: #17171a; + --bg-card: #1c1c20; + --fg: #ececee; + --fg-muted: #8a8a90; + --accent: #ff6b56; + --accent-hover: #ff8b78; + --border: #2a2a2e; + --danger: #ff6b6b; + --success: #6bcf6b; + } +} + +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + font-size: 1.0625rem; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.container { + max-width: 44rem; + margin-inline: auto; + padding-inline: 1.5rem; + width: 100%; +} + +main.container { + flex: 1; + padding-block: 2.5rem 4rem; +} + +a { + color: var(--accent); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent); + text-underline-offset: 3px; +} +a:hover { color: var(--accent-hover); } + +/* ── Header ─────────────────────────────────────────────────── */ + +.site-header { + border-bottom: 1px solid var(--border); + padding-block: 1rem; +} +.site-header .container { + display: flex; + align-items: center; + gap: 1rem; +} +.site-title { + display: inline-flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + color: inherit; + margin-right: auto; +} +.gate-mark { + color: var(--accent); + width: 1.15em; + height: 1.15em; + vertical-align: -0.15em; +} +.wordmark { + font-weight: 600; + letter-spacing: 0.14em; + color: var(--fg); + text-transform: uppercase; + font-size: 0.78rem; +} +.wordmark .sep { color: var(--fg-muted); margin: 0 0.4em; } + +.step-indicator { + font-family: var(--font-sans); + font-weight: 500; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-muted); +} + +/* ── Page heading ───────────────────────────────────────────── */ + +h1 { + font-family: var(--font-sans); + font-weight: 800; + font-size: clamp(2rem, 5vw, 2.75rem); + line-height: 1.05; + letter-spacing: -0.025em; + margin: 0 0 0.5rem; +} +.lede { + color: var(--fg-muted); + margin: 0 0 2rem; + font-size: 1.05rem; +} + +/* ── Forms ──────────────────────────────────────────────────── */ + +form { margin: 0; } + +.field { + display: block; + margin-bottom: 1.25rem; +} +.field > label { + display: block; + font-weight: 600; + font-size: 0.92rem; + margin-bottom: 0.35rem; + color: var(--fg); +} +.field .hint { + display: block; + font-size: 0.82rem; + color: var(--fg-muted); + margin-top: 0.3rem; +} + +input[type="text"], +input[type="password"], +select { + width: 100%; + padding: 0.65rem 0.85rem; + font-family: var(--font-sans); + font-size: 1rem; + background: var(--bg-card); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; + transition: border-color 120ms, box-shadow 120ms; +} +input[type="text"]:focus, +input[type="password"]:focus, +select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); +} + +/* ── Drive radio cards ──────────────────────────────────────── */ + +.drive-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.5rem; +} +.drive { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.85rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: border-color 120ms, background 120ms; +} +.drive:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } +.drive input[type="radio"] { + accent-color: var(--accent); + width: 1.1rem; + height: 1.1rem; + margin: 0; +} +.drive:has(input:checked) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 6%, var(--bg-card)); +} +.drive .name { + font-family: var(--font-mono); + font-weight: 600; + font-size: 0.95rem; +} +.drive .meta { + margin-left: auto; + display: flex; + gap: 0.5rem; + align-items: center; + font-size: 0.85rem; + color: var(--fg-muted); +} +.drive .score { + background: var(--bg-subtle); + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-family: var(--font-mono); + font-size: 0.78rem; +} + +/* ── Summary table (overview) ───────────────────────────────── */ + +.summary { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + margin-bottom: 1.5rem; +} +.summary table { + width: 100%; + border-collapse: collapse; +} +.summary td { + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--border); + font-size: 0.95rem; +} +.summary tr:last-child td { border-bottom: none; } +.summary td:first-child { + color: var(--fg-muted); + width: 12rem; + text-transform: capitalize; +} +.summary td:last-child { + font-family: var(--font-mono); + font-size: 0.9rem; +} + +/* ── Buttons ────────────────────────────────────────────────── */ + +.actions { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1.5rem; + flex-wrap: wrap; +} + +.btn { + display: inline-block; + padding: 0.7rem 1.25rem; + font-family: var(--font-sans); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.01em; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; + text-decoration: none; + transition: background 120ms, border-color 120ms, color 120ms; +} +.btn-primary { + background: var(--accent); + color: #fff; +} +.btn-primary:hover { background: var(--accent-hover); color: #fff; } +.btn-primary:disabled { + background: var(--bg-subtle); + color: var(--fg-muted); + cursor: not-allowed; +} +.btn-danger { + background: var(--danger); + color: #fff; +} +.btn-danger:hover { filter: brightness(1.1); color: #fff; } +.btn-link { + background: transparent; + color: var(--fg-muted); + padding: 0.7rem 0; + border: none; + text-decoration: none; +} +.btn-link:hover { color: var(--accent); } + +/* ── Alerts ─────────────────────────────────────────────────── */ + +.alert { + padding: 0.85rem 1rem; + border-radius: 6px; + margin-bottom: 1.5rem; + border: 1px solid; + font-size: 0.92rem; +} +.alert-error { + background: color-mix(in srgb, var(--danger) 8%, var(--bg-card)); + border-color: color-mix(in srgb, var(--danger) 35%, transparent); + color: var(--danger); +} +.alert-warn { + background: color-mix(in srgb, var(--accent) 8%, var(--bg-card)); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); + color: var(--accent); +} +.alert ul { margin: 0.4rem 0 0; padding-left: 1.2rem; } +.alert li { margin-bottom: 0.2rem; } + +/* ── Log pane ───────────────────────────────────────────────── */ + +.log { + background: #0d0d0f; + color: #e0e0e3; + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.2rem; + font-family: var(--font-mono); + font-size: 0.82rem; + line-height: 1.5; + max-height: 65vh; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +/* ── Footer ─────────────────────────────────────────────────── */ + +.site-footer { + margin-top: auto; + border-top: 1px solid var(--border); + padding-block: 1rem; +} +.site-footer .container { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; +} +.site-footer .kicker { + font-family: var(--font-sans); + font-weight: 500; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-muted); + margin: 0; +} + +/* ── Selection + focus ──────────────────────────────────────── */ + +::selection { background: var(--accent); color: var(--bg); } +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + border-radius: 2px; +} diff --git a/webinstaller/templates/base.html b/webinstaller/templates/base.html index e69de29..da311b6 100644 --- a/webinstaller/templates/base.html +++ b/webinstaller/templates/base.html @@ -0,0 +1,38 @@ + + +
+ + +This page reloads every 3 seconds. Don't close it. Don't power off.
+ +{{ log }}
+{% endblock %}
diff --git a/webinstaller/templates/install/overview.html b/webinstaller/templates/install/overview.html
index 69b3c26..9878d5f 100644
--- a/webinstaller/templates/install/overview.html
+++ b/webinstaller/templates/install/overview.html
@@ -1,13 +1,31 @@
-
-
-
- {{k}}: {{s}}
+{% extends "base.html" %} + +{% block title %}Confirm · Furtka Installer{% endblock %} +{% block step_indicator %}Step 3 of 3{% endblock %} + +{% block content %} +Review the settings below. Continuing will wipe {{ settings.boot_drive }} and install Furtka.
| {{ k.replace('_', ' ') }} | +{{ v }} | +
Set the server name and your administrator account.
+ +{% if errors %} +Pick the disk Furtka will install onto. The highest-scored drive is recommended.
+ +{% if not drives %} +