Add firstboot onboarding and prep/check tooling

This commit is contained in:
Aaron
2026-01-02 22:28:57 -05:00
parent ccc97f7912
commit 40b1b43449
20 changed files with 1487 additions and 220 deletions

View File

@@ -31,6 +31,8 @@ export async function api(path, opts = {}) {
}
export const getStatus = () => api("/api/status");
export const getFirstbootStatus = () => api("/api/firstboot");
export const getFirstbootError = () => api("/api/firstboot/error");
export const toggleUpdates = (enable) =>
api("/api/updates/auto", {
method: "POST",

View File

@@ -255,6 +255,94 @@
box-shadow: var(--shadow);
}
.firstboot-overlay .overlay-box {
width: min(92vw, 980px);
max-width: 980px;
text-align: left;
padding: 24px;
max-height: 90vh;
overflow: auto;
}
.firstboot-header h3 {
margin-bottom: 6px;
}
.firstboot-body {
display: grid;
grid-template-columns: 220px 1fr;
gap: 18px;
margin-top: 16px;
}
.firstboot-steps-list {
list-style: none;
padding: 0;
margin: 8px 0 0;
display: grid;
gap: 10px;
}
.firstboot-step {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.95rem;
color: var(--muted);
}
.firstboot-step .step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border);
flex: 0 0 10px;
}
.firstboot-step.current {
color: var(--text);
font-weight: 600;
}
.firstboot-step.current .step-dot {
background: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.12);
}
.firstboot-step.done {
color: var(--text);
opacity: 0.7;
}
.firstboot-step.done .step-dot {
background: #22c55e;
}
.firstboot-step.error {
color: #ef4444;
font-weight: 600;
}
.firstboot-step.error .step-dot {
background: #ef4444;
}
.firstboot-current {
margin: 0 0 6px;
font-weight: 600;
}
.firstboot-log .log-box {
max-height: 240px;
min-height: 140px;
}
@media (max-width: 840px) {
.firstboot-body {
grid-template-columns: 1fr;
}
}
.spinner {
margin: 12px auto 4px;
width: 32px;

View File

@@ -0,0 +1,142 @@
const STATUS_CLASS = {
pending: "pending",
current: "current",
running: "current",
done: "done",
error: "error",
};
function normalizeStatus(status) {
const key = (status || "pending").toString().toLowerCase();
return STATUS_CLASS[key] || "pending";
}
function currentStepLabel(steps = [], fallback = "") {
const current = steps.find((step) => {
const status = typeof step === "string" ? "pending" : step.status;
return ["current", "running", "error"].includes(status);
});
if (current) {
return typeof current === "string" ? current : current.label;
}
const first = steps.find((step) => (typeof step === "string" ? step : step.label));
if (first) return typeof first === "string" ? first : first.label;
return fallback || "";
}
function renderSteps(stepsEl, steps = []) {
if (!stepsEl) return;
stepsEl.innerHTML = "";
steps.forEach((step) => {
const li = document.createElement("li");
const status = normalizeStatus(step.status);
li.className = `firstboot-step ${status}`;
const dot = document.createElement("span");
dot.className = "step-dot";
dot.setAttribute("aria-hidden", "true");
const label = document.createElement("span");
label.className = "step-label";
label.textContent = step.label || "";
li.appendChild(dot);
li.appendChild(label);
stepsEl.appendChild(li);
});
}
function setLogText(logEl, text) {
if (!logEl) return;
const value = text && text.trim().length ? text : "Waiting for setup logs...";
logEl.textContent = value;
logEl.scrollTop = logEl.scrollHeight;
}
function wireCopyButton(btn, getText, showToast) {
if (!btn) return;
btn.addEventListener("click", async () => {
const text = getText();
if (!text) return;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
showToast?.("Copied error log", "success");
} catch (err) {
showToast?.("Copy failed", "error");
}
});
}
export function createFirstbootUI({
overlay,
stepsEl,
currentStepEl,
logEl,
logNoteEl,
errorModal,
errorLogEl,
errorCloseBtn,
errorCopyBtn,
errorShowRecoveryBtn,
recoveryEl,
showToast,
}) {
let lastErrorText = "";
if (errorModal) {
errorModal.addEventListener("click", (e) => {
if (e.target === errorModal) errorModal.classList.add("hidden");
});
}
errorCloseBtn?.addEventListener("click", () => errorModal?.classList.add("hidden"));
errorShowRecoveryBtn?.addEventListener("click", () => recoveryEl?.classList.toggle("hidden"));
wireCopyButton(errorCopyBtn, () => lastErrorText, showToast);
function update(data) {
if (!data) return;
const steps = Array.isArray(data.steps) ? data.steps : [];
const current = data.current_step || currentStepLabel(steps);
renderSteps(
stepsEl,
steps.map((step) => {
const label = typeof step === "string" ? step : step.label || "";
const status = typeof step === "string" ? "pending" : step.status;
return { label, status: normalizeStatus(status) };
})
);
if (currentStepEl) {
currentStepEl.textContent = current ? `Current step: ${current}` : "Current step: preparing";
}
setLogText(logEl, data.log_tail || "");
if (logNoteEl) logNoteEl.textContent = "If this stalls for more than 10 minutes, refresh the page or check SSH.";
}
function showOverlay(show) {
if (!overlay) return;
overlay.classList.toggle("hidden", !show);
}
function showError(text) {
lastErrorText = text || "";
if (errorLogEl) {
errorLogEl.textContent = lastErrorText || "(no error log found)";
}
if (errorModal) errorModal.classList.remove("hidden");
}
return {
update,
showOverlay,
showError,
};
}

View File

@@ -1,6 +1,7 @@
// Entry point for the dashboard: wires UI events, pulls status, and initializes
// feature modules (services, settings, stats).
import { getStatus, triggerReset } from "./api.js";
import { getStatus, getFirstbootError, getFirstbootStatus, triggerReset } from "./api.js";
import { createFirstbootUI } from "./firstboot-ui.js";
import { initServiceControls } from "./services.js";
import { placeholderStatus, renderStats } from "./status.js";
import { initSettings } from "./settings.js";
@@ -99,6 +100,16 @@ const busyTitle = document.getElementById("busyTitle");
const busyText = document.getElementById("busyText");
const toastContainer = document.getElementById("toastContainer");
const readyOverlay = document.getElementById("readyOverlay");
const firstbootSteps = document.getElementById("firstbootSteps");
const firstbootCurrentStep = document.getElementById("firstbootCurrentStep");
const firstbootLog = document.getElementById("firstbootLog");
const firstbootLogNote = document.getElementById("firstbootLogNote");
const firstbootErrorModal = document.getElementById("firstbootErrorModal");
const firstbootErrorLog = document.getElementById("firstbootErrorLog");
const firstbootErrorClose = document.getElementById("firstbootErrorClose");
const firstbootCopyError = document.getElementById("firstbootCopyError");
const firstbootShowRecovery = document.getElementById("firstbootShowRecovery");
const firstbootRecovery = document.getElementById("firstbootRecovery");
const confirmModal = document.getElementById("confirmModal");
const confirmTitle = document.getElementById("confirmTitle");
const confirmBody = document.getElementById("confirmBody");
@@ -146,6 +157,21 @@ const confirmAction = createConfirmModal({
});
const collapseAccordions = () => document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
const firstbootUI = createFirstbootUI({
overlay: readyOverlay,
stepsEl: firstbootSteps,
currentStepEl: firstbootCurrentStep,
logEl: firstbootLog,
logNoteEl: firstbootLogNote,
errorModal: firstbootErrorModal,
errorLogEl: firstbootErrorLog,
errorCloseBtn: firstbootErrorClose,
errorCopyBtn: firstbootCopyError,
errorShowRecoveryBtn: firstbootShowRecovery,
recoveryEl: firstbootRecovery,
showToast,
});
const statusController = createStatusController({
heroStats,
servicesGrid,
@@ -160,6 +186,11 @@ const statusController = createStatusController({
updatesFlagEl: setUpdatesFlag,
releaseUIGetter: () => releaseUI,
onReadyWait: () => setTimeout(() => statusController.loadStatus(), 3000),
firstboot: {
getStatus: getFirstbootStatus,
getError: getFirstbootError,
ui: firstbootUI,
},
});
const { loadStatus } = statusController;

View File

@@ -17,8 +17,10 @@ export function createStatusController({
releaseUIGetter = () => null,
setUpdatesUI = null,
updatesFlagEl = null,
firstboot = null,
}) {
let lastStatusData = null;
let lastFirstbootState = null;
function setTempFlag(tempC) {
if (!tempFlagTop) return;
@@ -77,7 +79,30 @@ export function createStatusController({
}
}
}
if (readyOverlay) {
if (firstboot?.getStatus && firstboot?.ui) {
let firstbootData = null;
const shouldFetchFirstboot =
lastFirstbootState === null || !data.ready || lastFirstbootState === "running" || lastFirstbootState === "error";
if (shouldFetchFirstboot) {
try {
firstbootData = await firstboot.getStatus();
lastFirstbootState = firstbootData?.state || lastFirstbootState;
firstboot.ui.update(firstbootData);
if (firstbootData?.state === "error" && firstboot.getError) {
const err = await firstboot.getError();
if (err?.present) {
firstboot.ui.showError(err.text || "");
}
}
} catch (err) {
logUi?.(`First-boot status failed: ${err?.message || err}`, "error");
}
}
const readyNow = data.ready || firstbootData?.state === "done";
const showOverlay = !readyNow || firstbootData?.state === "error";
firstboot.ui.showOverlay(showOverlay);
if (showOverlay) onReadyWait?.();
} else if (readyOverlay) {
if (data.ready) {
readyOverlay.classList.add("hidden");
} else {