feat: console welcome with proksi.local + post-install reboot flow
Two user-visible polish passes on top of the walking-skeleton install: - Console welcome: live ISO's getty no longer shows the bare Arch prompt. `/etc/hostname` is now `proksi` so avahi advertises `proksi.local`; a systemd oneshot (`furtka-issue.service`, runs after network-online.target) regenerates `/etc/issue` via `/usr/local/bin/furtka-update-issue` to show both `http://proksi.local:5000` (preferred, via mDNS — avahi and nss-mdns are already in `packages.extra`) and the raw IP as a fallback for networks where mDNS is flaky. `agetty --reload` nudges the already- running login prompt to redraw. - /install/log now polls a JSON endpoint (`/install/log.json`) every 3 s instead of meta-refresh, so expanding the collapsed log `<details>` doesn't get eaten by the refresh. Noscript fallback keeps the meta-refresh for JS-off users. When the install finishes, the Done state shows a Reboot-now button that POSTs to `/install/reboot` (guarded server-side to only reboot once status is "done", so a panicked click mid-pacstrap can't brick the box). A confirm() reminds the user to pull the USB / eject the ISO first. End-to-end tested on a Proxmox VM 2026-04-14: boot → wizard → archinstall → Done state → Reboot now → VM came back up → login as created user → `docker ps` worked. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a259beb98
commit
7442dbe47e
8 changed files with 152 additions and 12 deletions
1
iso/overlay/airootfs/etc/hostname
Normal file
1
iso/overlay/airootfs/etc/hostname
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
proksi
|
||||||
6
iso/overlay/airootfs/etc/issue
Normal file
6
iso/overlay/airootfs/etc/issue
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
Furtka Live Installer starting…
|
||||||
|
|
||||||
|
Once ready, open http://proksi.local:5000 on another device
|
||||||
|
on your network. The exact URL will appear below.
|
||||||
|
|
||||||
12
iso/overlay/airootfs/etc/systemd/system/furtka-issue.service
Normal file
12
iso/overlay/airootfs/etc/systemd/system/furtka-issue.service
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Write Furtka /etc/issue with current IP for the console welcome
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/furtka-update-issue
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../furtka-issue.service
|
||||||
23
iso/overlay/airootfs/usr/local/bin/furtka-update-issue
Executable file
23
iso/overlay/airootfs/usr/local/bin/furtka-update-issue
Executable file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Regenerates /etc/issue so the live-ISO console tells the user which URL
|
||||||
|
# to open in their browser. Shows proksi.local (via avahi/mDNS) as the
|
||||||
|
# preferred URL and the raw IP as a fallback for networks where mDNS
|
||||||
|
# doesn't work. Reload at the end nudges agetty to redraw.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo
|
||||||
|
echo " Open Furtka in a browser on another device on your network:"
|
||||||
|
echo
|
||||||
|
echo " http://proksi.local:5000 (easy — try this first)"
|
||||||
|
if [ -n "$ip" ]; then
|
||||||
|
echo " http://${ip}:5000 (fallback if the first doesn't work)"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo " Then follow the wizard to install Furtka on this machine."
|
||||||
|
echo
|
||||||
|
} > /etc/issue
|
||||||
|
|
||||||
|
agetty --reload 2>/dev/null || true
|
||||||
|
|
@ -5,7 +5,7 @@ import subprocess
|
||||||
from pathlib import Path
|
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, jsonify, redirect, render_template, request, url_for
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
@ -269,5 +269,25 @@ def install_log_view():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/install/log.json")
|
||||||
|
def install_log_json():
|
||||||
|
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
||||||
|
return jsonify(log=log, progress=parse_install_progress(log))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/install/reboot", methods=["POST"])
|
||||||
|
def install_reboot():
|
||||||
|
# Only allow rebooting once the install has actually finished — we don't
|
||||||
|
# want a panicked click during install to reboot mid-pacstrap.
|
||||||
|
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
||||||
|
if parse_install_progress(log)["status"] != "done":
|
||||||
|
return redirect(url_for("install_log_view"))
|
||||||
|
subprocess.Popen(
|
||||||
|
["/usr/bin/systemctl", "reboot"],
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
return render_template("install/rebooting.html")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True, port=5000)
|
app.run(debug=True, port=5000)
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,96 @@
|
||||||
|
|
||||||
{% block title %}Installing… · Furtka Installer{% endblock %}
|
{% block title %}Installing… · Furtka Installer{% endblock %}
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
|
{# Fallback for users with JS disabled — otherwise the JS below takes over
|
||||||
|
and updates in-place so the log <details> doesn't re-collapse. #}
|
||||||
|
<noscript>
|
||||||
{% if progress.status == "running" %}<meta http-equiv="refresh" content="3">{% endif %}
|
{% if progress.status == "running" %}<meta http-equiv="refresh" content="3">{% endif %}
|
||||||
|
</noscript>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block step_indicator %}<span class="step-indicator">Installing</span>{% endblock %}
|
{% block step_indicator %}<span class="step-indicator">Installing</span>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<h1 id="install-heading">
|
||||||
|
{% if progress.status == "done" %}Furtka is ready
|
||||||
|
{% elif progress.status == "error" %}Installation hit a snag
|
||||||
|
{% else %}Installing Furtka{% endif %}
|
||||||
|
</h1>
|
||||||
|
<p class="lede" id="install-lede">
|
||||||
|
{% if progress.status == "done" %}Installation finished. <strong>Remove the installer USB / eject the ISO</strong>, then click Reboot.
|
||||||
|
{% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help.
|
||||||
|
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
{% if progress.status == "done" %}
|
{% if progress.status == "done" %}
|
||||||
<h1>Furtka is ready</h1>
|
<form method="post" action="{{ url_for('install_reboot') }}"
|
||||||
<p class="lede">Installation finished. Remove the USB / eject the installer image, then reboot.</p>
|
onsubmit="return confirm('Have you removed the installer USB / ejected the ISO? Click OK to reboot.');">
|
||||||
{% elif progress.status == "error" %}
|
<div class="actions">
|
||||||
<h1>Installation hit a snag</h1>
|
<button type="submit" class="btn btn-primary">Reboot now</button>
|
||||||
<p class="lede">Something went wrong. Open the details below and share them so we can help.</p>
|
</div>
|
||||||
{% else %}
|
</form>
|
||||||
<h1>Installing Furtka</h1>
|
|
||||||
<p class="lede">This takes a few minutes. Don't close this page or power off the machine.</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}">
|
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}">
|
||||||
<div class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div>
|
<div id="progress-bar" class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="progress-phase">{{ progress.phase }} · {{ progress.percent }}%</p>
|
<p class="progress-phase"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p>
|
||||||
|
|
||||||
<details class="log-details">
|
<details class="log-details">
|
||||||
<summary>Show details</summary>
|
<summary>Show details</summary>
|
||||||
<pre class="log">{{ log or "(waiting for install to start)" }}</pre>
|
<pre id="install-log" class="log">{{ log or "(waiting for install to start)" }}</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var initialStatus = {{ progress.status | tojson }};
|
||||||
|
if (initialStatus !== 'running') return;
|
||||||
|
|
||||||
|
var bar = document.getElementById('progress-bar');
|
||||||
|
var phaseEl = document.getElementById('progress-phase');
|
||||||
|
var percentEl = document.getElementById('progress-percent');
|
||||||
|
var logEl = document.getElementById('install-log');
|
||||||
|
var headingEl = document.getElementById('install-heading');
|
||||||
|
var ledeEl = document.getElementById('install-lede');
|
||||||
|
|
||||||
|
var HEADINGS = {
|
||||||
|
done: 'Furtka is ready',
|
||||||
|
error: 'Installation hit a snag',
|
||||||
|
};
|
||||||
|
var LEDES = {
|
||||||
|
done: 'Installation finished. Remove the USB / eject the installer image, then reboot.',
|
||||||
|
error: 'Something went wrong. Open the details below and share them so we can help.',
|
||||||
|
};
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var p = data.progress;
|
||||||
|
bar.style.width = p.percent + '%';
|
||||||
|
phaseEl.textContent = p.phase;
|
||||||
|
percentEl.textContent = p.percent;
|
||||||
|
if (logEl.textContent !== data.log) {
|
||||||
|
var atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 8;
|
||||||
|
logEl.textContent = data.log || '(waiting for install to start)';
|
||||||
|
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
if (p.status === 'done') {
|
||||||
|
// Reload so the server-rendered Done state (with the
|
||||||
|
// Reboot button) replaces the running-state markup.
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p.status === 'error') {
|
||||||
|
bar.classList.add('progress-bar-error');
|
||||||
|
headingEl.textContent = HEADINGS.error;
|
||||||
|
ledeEl.textContent = LEDES.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(tick, 3000);
|
||||||
|
})
|
||||||
|
.catch(function () { setTimeout(tick, 3000); });
|
||||||
|
}
|
||||||
|
setTimeout(tick, 3000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
10
webinstaller/templates/install/rebooting.html
Normal file
10
webinstaller/templates/install/rebooting.html
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Rebooting · Furtka Installer{% endblock %}
|
||||||
|
{% block step_indicator %}<span class="step-indicator">Done</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Rebooting…</h1>
|
||||||
|
<p class="lede">The machine is restarting. This page will stop responding in a moment — that's expected.</p>
|
||||||
|
<p>When the machine comes back up, log in with the username and password you set during the install.</p>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Reference in a new issue