feat: webinstaller writes archinstall config + execs install, styled
Some checks failed
CI / lint (push) Failing after 25s
CI / test (push) Successful in 31s
CI / validate-json (push) Successful in 23s
CI / markdown-links (push) Failing after 2s

Wires the live-ISO wizard from "shows three screens" to "actually invokes
archinstall on the chosen disk", plus first-pass styling so it stops looking
like raw <h1>/<form>.

Webinstaller flow:
- S1 form gains username/password/password2/language with server-side
  validation (hostname/username regex, ≥8 char password, match check).
- /install/run writes user_configuration.json + user_credentials.json
  (creds 0600) to FURTKA_STATE_DIR (default /tmp/furtka), then execs
  `archinstall --config … --creds … --silent` as a backgrounded subprocess.
- /install/log renders the subprocess output via meta-refresh polling.
- FURTKA_DRY_RUN=1 short-circuits the exec for testing.
- archinstall flag names verified against `archinstall --help` in an
  archlinux container before committing.

Drive list:
- drives.py now filters via `lsblk … -o NAME,SIZE,TYPE` keeping TYPE=disk,
  so the live ISO's own squashfs (loop) and CD-ROM (rom) stop appearing
  as install targets.

Boot menu:
- iso/build.sh sed-rebrands "Arch Linux install medium" →
  "Furtka Live Installer" across grub/, syslinux/, and efiboot/loader/
  entries. Verified zero leftovers against the current releng profile.

Styling:
- static/style.css adopts the website's design tokens (palette,
  typography, gate-mark accent), with light + dark via prefers-color-scheme.
- New base.html with header (gate SVG + FURTKA·INSTALLER wordmark + step
  indicator) and footer; all install templates extend it.
- Drive picker uses radio cards with score chip; overview uses a summary
  table and a destructive "wipe drive" button.

Tests: 17 pass (4 new in test_app.py covering validation + config builders,
2 new in test_drives.py covering the lsblk filter). Ruff clean.

README roadmap updated to mark these done and explicitly defer the
26.0-alpha release until archinstall actually completes end-to-end on a VM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-14 10:54:49 +02:00
parent defd2eda06
commit 15b876c70a
12 changed files with 819 additions and 82 deletions

View file

@ -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 13 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 S4S8 (user/password, domain, SSL, diagnostic, confirm) + actually invoking `archinstall` on the chosen disk
- [ ] Installer wizard screens S3S7 — 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

View file

@ -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__"

61
tests/test_app.py Normal file
View file

@ -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"}]

View file

@ -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("") == []

View file

@ -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__":

View file

@ -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():

View file

@ -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;
}

View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Furtka Installer{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head_extra %}{% endblock %}
</head>
<body>
<header class="site-header">
<div class="container">
<a href="{{ url_for('install_step_1') }}" class="site-title" aria-label="Furtka Installer — restart">
<svg class="gate-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 20 V12 A9 9 0 0 1 20 12 V20"/>
<line x1="12" y1="5" x2="12" y2="20"/>
<line x1="3" y1="20" x2="21" y2="20"/>
<line x1="15" y1="12" x2="15" y2="14.5"/>
</svg>
<span class="wordmark">Furtka<span class="sep">·</span>Installer</span>
</a>
{% block step_indicator %}{% endblock %}
</div>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<div class="container">
<p class="kicker">Furtka 26.0-alpha · AGPL-3.0</p>
<p class="kicker"><a href="https://furtka.org" style="color: inherit; text-decoration: none">furtka.org</a></p>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Installing… · Furtka Installer{% endblock %}
{% block head_extra %}<meta http-equiv="refresh" content="3">{% endblock %}
{% block step_indicator %}<span class="step-indicator">Installing</span>{% endblock %}
{% block content %}
<h1>Installing Furtka</h1>
<p class="lede">This page reloads every 3 seconds. Don't close it. Don't power off.</p>
<pre class="log">{{ log }}</pre>
{% endblock %}

View file

@ -1,13 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>Furtka Install</title>
</head>
<body>
<h1>Overview</h1>
<h2>Results:</h2>
{% for k, s in settings.items() %}
<p>{{k}}: {{s}}</p>
{% extends "base.html" %}
{% block title %}Confirm · Furtka Installer{% endblock %}
{% block step_indicator %}<span class="step-indicator">Step 3 of 3</span>{% endblock %}
{% block content %}
<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>
<div class="alert alert-warn">
This is destructive. All existing data on the selected boot drive will be lost.
</div>
<div class="summary">
<table>
{% for k, v in settings.items() %}
<tr>
<td>{{ k.replace('_', ' ') }}</td>
<td>{{ v }}</td>
</tr>
{% endfor %}
</body>
</html>
</table>
</div>
<form method="post" action="{{ url_for('install_run') }}">
<div class="actions">
<button type="submit" class="btn btn-danger">Install — wipe drive and proceed</button>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Start over</a>
</div>
</form>
{% endblock %}

View file

@ -1,13 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<title>Furtka Install</title>
</head>
<body>
<h1>Step 1</h1>
<form method="post" action="{{ url_for('install_step_1') }}">
<p>Hostname: <input type="text" name="hostname" required /></p>
<input type="submit" value="Next" />
</form>
</body>
</html>
{% extends "base.html" %}
{% block title %}Step 1 — Account · Furtka Installer{% endblock %}
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
{% block content %}
<h1>Account &amp; hostname</h1>
<p class="lede">Set the server name and your administrator account.</p>
{% if errors %}
<div class="alert alert-error">
<strong>Please fix the following:</strong>
<ul>
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
</ul>
</div>
{% endif %}
<form method="post" action="{{ url_for('install_step_1') }}">
<div class="field">
<label for="hostname">Hostname</label>
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" />
<span class="hint">Lowercase letters, digits, hyphens. Used on the local network.</span>
</div>
<div class="field">
<label for="username">Admin username</label>
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" />
<span class="hint">Linux user account with sudo + docker access.</span>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="new-password" />
<span class="hint">At least 8 characters.</span>
</div>
<div class="field">
<label for="password2">Confirm password</label>
<input type="password" id="password2" name="password2" required autocomplete="new-password" />
</div>
<div class="field">
<label for="language">Language</label>
<select id="language" name="language">
{% for code, lang in languages.items() %}
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
{% endfor %}
</select>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">Next — pick boot drive</button>
</div>
</form>
{% endblock %}

View file

@ -1,19 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<title>Furtka Install</title>
</head>
<body>
<h1>Step 2 - Choose Boot Drive</h1>
<form method="post" action="{{ url_for('install_step_2') }}">
<p>Boot Drive:
<select name="boot_drive_uuid" required>
{% for d in drives %}
<option value="{{ d.name }}">{{ d.name }} ({{ d.size }}, score {{ d.score }})</option>
{% endfor %}
</select>
</p>
<input type="submit" value="Go to Overview" />
</form>
</body>
</html>
{% extends "base.html" %}
{% block title %}Step 2 — Boot drive · Furtka Installer{% endblock %}
{% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %}
{% block content %}
<h1>Boot drive</h1>
<p class="lede">Pick the disk Furtka will install onto. The highest-scored drive is recommended.</p>
{% if not drives %}
<div class="alert alert-error">
No physical disks detected. The live ISO can't see any install targets — check your BIOS / SATA controller.
</div>
{% endif %}
<form method="post" action="{{ url_for('install_step_2') }}">
<div class="drive-list">
{% for d in drives %}
<label class="drive">
<input type="radio" name="boot_drive" value="{{ d.name }}"
{% if (selected and d.name == selected) or (not selected and loop.first) %}checked{% endif %} />
<span class="name">{{ d.name }}</span>
<span class="meta">
<span>{{ d.size }}</span>
<span class="score">score {{ d.score }}</span>
</span>
</label>
{% endfor %}
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" {% if not drives %}disabled{% endif %}>Next — review</button>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Back</a>
</div>
</form>
{% endblock %}