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] 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
|
||||
|
|
|
|||
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/"
|
||||
|
||||
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
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 (
|
||||
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("") == []
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
errors, values = validate_step1(request.form)
|
||||
if not errors:
|
||||
settings.update(values)
|
||||
return redirect(url_for("install_step_2"))
|
||||
return render_template("install/step1.html")
|
||||
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"]
|
||||
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())
|
||||
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__":
|
||||
|
|
|
|||
|
|
@ -54,23 +54,22 @@ def score_device(device, size_gb):
|
|||
return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb)
|
||||
|
||||
|
||||
def list_scored_devices():
|
||||
"""Return [{name, size, score}, ...] for all physical disks, highest score first."""
|
||||
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 = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["lsblk", "-dn", "-o", "NAME,SIZE"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
for line in output.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
name, size, dev_type = parts[0], parts[1], parts[2]
|
||||
if dev_type != "disk":
|
||||
continue
|
||||
name, size = parts[0], parts[1]
|
||||
device = f"/dev/{name}"
|
||||
devices.append(
|
||||
{
|
||||
|
|
@ -79,13 +78,25 @@ def list_scored_devices():
|
|||
"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
|
||||
|
||||
|
||||
def list_scored_devices():
|
||||
"""Return [{name, size, score}, ...] for all physical disks, highest score first."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["lsblk", "-dn", "-o", "NAME,SIZE,TYPE"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error listing devices: {e}")
|
||||
return []
|
||||
return parse_lsblk_output(result.stdout)
|
||||
|
||||
|
||||
def main():
|
||||
devices = list_scored_devices()
|
||||
if not devices:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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 %}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,52 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Furtka Install</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Step 1</h1>
|
||||
{% 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 & 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') }}">
|
||||
<p>Hostname: <input type="text" name="hostname" required /></p>
|
||||
<input type="submit" value="Next" />
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,36 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Furtka Install</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Step 2 - Choose Boot Drive</h1>
|
||||
{% 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') }}">
|
||||
<p>Boot Drive:
|
||||
<select name="boot_drive_uuid" required>
|
||||
<div class="drive-list">
|
||||
{% for d in drives %}
|
||||
<option value="{{ d.name }}">{{ d.name }} ({{ d.size }}, score {{ d.score }})</option>
|
||||
<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 %}
|
||||
</select>
|
||||
</p>
|
||||
<input type="submit" value="Go to Overview" />
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue