feat: webinstaller writes archinstall config + execs install, styled
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:
parent
defd2eda06
commit
15b876c70a
12 changed files with 819 additions and 82 deletions
|
|
@ -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] 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] 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/)
|
- [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/).
|
- [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`.
|
- [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.
|
||||||
- [ ] **Rebrand GRUB menu** — the ISO still boots as "Arch Linux install medium". Cosmetic, fix when we start caring about end-user-facing polish.
|
- [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.
|
- [ ] **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)
|
- [ ] `https://proksi.local` via mDNS + local CA (currently only raw-IP HTTP)
|
||||||
- [ ] Caddy + Authentik wired into first-boot bootstrap
|
- [ ] Caddy + Authentik wired into first-boot bootstrap
|
||||||
- [ ] Managed gateway infrastructure — `ns1/ns2.furtka.org` + DNS-01 wildcard automation
|
- [ ] Managed gateway infrastructure — `ns1/ns2.furtka.org` + DNS-01 wildcard automation
|
||||||
|
|
|
||||||
10
iso/build.sh
10
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/"
|
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"
|
mkdir -p "$PROFILE_WORK/airootfs/opt/furtka"
|
||||||
cp -a "$REPO_ROOT/webinstaller/." "$PROFILE_WORK/airootfs/opt/furtka/"
|
cp -a "$REPO_ROOT/webinstaller/." "$PROFILE_WORK/airootfs/opt/furtka/"
|
||||||
rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__"
|
rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__"
|
||||||
|
|
|
||||||
61
tests/test_app.py
Normal file
61
tests/test_app.py
Normal 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"}]
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from drives import (
|
from drives import (
|
||||||
get_drive_type_score,
|
get_drive_type_score,
|
||||||
get_size_score,
|
get_size_score,
|
||||||
|
parse_lsblk_output,
|
||||||
parse_size_gb,
|
parse_size_gb,
|
||||||
score_device,
|
score_device,
|
||||||
)
|
)
|
||||||
|
|
@ -55,3 +56,22 @@ def test_score_device_sums_type_and_size(monkeypatch):
|
||||||
monkeypatch.setattr(drives, "get_drive_health", lambda _: 10)
|
monkeypatch.setattr(drives, "get_drive_health", lambda _: 10)
|
||||||
assert score_device("/dev/nvme0n1", 1024) == 15 + 10 + 10
|
assert score_device("/dev/nvme0n1", 1024) == 15 + 10 + 10
|
||||||
assert score_device("/dev/sda", 64) == 5 + 10 + 5
|
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("") == []
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,185 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from drives import list_scored_devices
|
from drives import list_scored_devices
|
||||||
from flask import Flask, redirect, render_template, request, url_for
|
from flask import Flask, redirect, render_template, request, url_for
|
||||||
|
|
||||||
app = Flask(__name__)
|
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 = {
|
settings = {
|
||||||
# Step 1
|
|
||||||
"hostname": "furtka",
|
"hostname": "furtka",
|
||||||
"username": "",
|
"username": "",
|
||||||
"password": "",
|
"password": "",
|
||||||
"password2": "",
|
|
||||||
"backend": False,
|
|
||||||
"backend_adress": "127.0.0.1",
|
|
||||||
"language": "en",
|
"language": "en",
|
||||||
# devices
|
"boot_drive": "",
|
||||||
"boot_drive_uuid": "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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("/")
|
@app.route("/")
|
||||||
def home():
|
def home():
|
||||||
return "Hello World"
|
return redirect(url_for("install_step_1"))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/install/step1", methods=["GET", "POST"])
|
@app.route("/install/step1", methods=["GET", "POST"])
|
||||||
def install_step_1():
|
def install_step_1():
|
||||||
|
errors = []
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
settings["hostname"] = request.form["hostname"]
|
errors, values = validate_step1(request.form)
|
||||||
return redirect(url_for("install_step_2"))
|
if not errors:
|
||||||
return render_template("install/step1.html")
|
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"])
|
@app.route("/install/step2", methods=["GET", "POST"])
|
||||||
def install_step_2():
|
def install_step_2():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
settings["boot_drive_uuid"] = request.form["boot_drive_uuid"]
|
boot_drive = request.form.get("boot_drive", "").strip()
|
||||||
return redirect(url_for("install_overview"))
|
if boot_drive:
|
||||||
return render_template("install/step2.html", drives=list_scored_devices())
|
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")
|
@app.route("/install/overview")
|
||||||
def 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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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():
|
def list_scored_devices():
|
||||||
"""Return [{name, size, score}, ...] for all physical disks, highest score first."""
|
"""Return [{name, size, score}, ...] for all physical disks, highest score first."""
|
||||||
devices = []
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["lsblk", "-dn", "-o", "NAME,SIZE"],
|
["lsblk", "-dn", "-o", "NAME,SIZE,TYPE"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=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:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f"Error listing devices: {e}")
|
print(f"Error listing devices: {e}")
|
||||||
|
return []
|
||||||
devices.sort(key=lambda d: d["score"], reverse=True)
|
return parse_lsblk_output(result.stdout)
|
||||||
return devices
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
12
webinstaller/templates/install/log.html
Normal file
12
webinstaller/templates/install/log.html
Normal 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 %}
|
||||||
|
|
@ -1,13 +1,31 @@
|
||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html>
|
|
||||||
<head>
|
{% block title %}Confirm · Furtka Installer{% endblock %}
|
||||||
<title>Furtka Install</title>
|
{% block step_indicator %}<span class="step-indicator">Step 3 of 3</span>{% endblock %}
|
||||||
</head>
|
|
||||||
<body>
|
{% block content %}
|
||||||
<h1>Overview</h1>
|
<h1>Confirm install</h1>
|
||||||
<h2>Results:</h2>
|
<p class="lede">Review the settings below. Continuing will <strong>wipe <code>{{ settings.boot_drive }}</code></strong> and install Furtka.</p>
|
||||||
{% for k, s in settings.items() %}
|
|
||||||
<p>{{k}}: {{s}}</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 %}
|
{% endfor %}
|
||||||
</body>
|
</table>
|
||||||
</html>
|
</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 %}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,52 @@
|
||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html>
|
|
||||||
<head>
|
{% block title %}Step 1 — Account · Furtka Installer{% endblock %}
|
||||||
<title>Furtka Install</title>
|
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
|
||||||
</head>
|
|
||||||
<body>
|
{% block content %}
|
||||||
<h1>Step 1</h1>
|
<h1>Account & hostname</h1>
|
||||||
<form method="post" action="{{ url_for('install_step_1') }}">
|
<p class="lede">Set the server name and your administrator account.</p>
|
||||||
<p>Hostname: <input type="text" name="hostname" required /></p>
|
|
||||||
<input type="submit" value="Next" />
|
{% if errors %}
|
||||||
</form>
|
<div class="alert alert-error">
|
||||||
</body>
|
<strong>Please fix the following:</strong>
|
||||||
</html>
|
<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 %}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,36 @@
|
||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html>
|
|
||||||
<head>
|
{% block title %}Step 2 — Boot drive · Furtka Installer{% endblock %}
|
||||||
<title>Furtka Install</title>
|
{% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %}
|
||||||
</head>
|
|
||||||
<body>
|
{% block content %}
|
||||||
<h1>Step 2 - Choose Boot Drive</h1>
|
<h1>Boot drive</h1>
|
||||||
<form method="post" action="{{ url_for('install_step_2') }}">
|
<p class="lede">Pick the disk Furtka will install onto. The highest-scored drive is recommended.</p>
|
||||||
<p>Boot Drive:
|
|
||||||
<select name="boot_drive_uuid" required>
|
{% if not drives %}
|
||||||
{% for d in drives %}
|
<div class="alert alert-error">
|
||||||
<option value="{{ d.name }}">{{ d.name }} ({{ d.size }}, score {{ d.score }})</option>
|
No physical disks detected. The live ISO can't see any install targets — check your BIOS / SATA controller.
|
||||||
{% endfor %}
|
</div>
|
||||||
</select>
|
{% endif %}
|
||||||
</p>
|
|
||||||
<input type="submit" value="Go to Overview" />
|
<form method="post" action="{{ url_for('install_step_2') }}">
|
||||||
</form>
|
<div class="drive-list">
|
||||||
</body>
|
{% for d in drives %}
|
||||||
</html>
|
<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 %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue