Add firstboot onboarding and prep/check tooling
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
142
pikit-web/assets/firstboot-ui.js
Normal file
142
pikit-web/assets/firstboot-ui.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -80,14 +80,55 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="readyOverlay" class="overlay hidden">
|
||||
<div class="overlay-box">
|
||||
<h3>Finishing setup</h3>
|
||||
<p>
|
||||
This only takes a couple of minutes. You'll see the dashboard once
|
||||
Pi-Kit setup completes.
|
||||
</p>
|
||||
<div class="spinner"></div>
|
||||
<div id="readyOverlay" class="overlay hidden firstboot-overlay">
|
||||
<div class="overlay-box firstboot-card" role="status" aria-live="polite">
|
||||
<div class="firstboot-header">
|
||||
<h3>Finishing setup</h3>
|
||||
<p class="hint">This usually takes a few minutes. Please keep this tab open.</p>
|
||||
</div>
|
||||
<div class="firstboot-body">
|
||||
<div class="firstboot-steps">
|
||||
<p class="eyebrow">Steps</p>
|
||||
<ol id="firstbootSteps" class="firstboot-steps-list"></ol>
|
||||
</div>
|
||||
<div class="firstboot-log">
|
||||
<p id="firstbootCurrentStep" class="firstboot-current">Current step: preparing</p>
|
||||
<p class="eyebrow">Live setup log</p>
|
||||
<pre id="firstbootLog" class="log-box"></pre>
|
||||
<p id="firstbootLogNote" class="hint"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="firstbootErrorModal" class="modal hidden">
|
||||
<div class="modal-card wide">
|
||||
<div class="panel-header sticky">
|
||||
<div>
|
||||
<p class="eyebrow">Setup</p>
|
||||
<h3>Setup needs attention</h3>
|
||||
<p class="hint">
|
||||
Pi-Kit couldn’t finish setup automatically. Nothing is broken, but a manual fix is needed.
|
||||
Use SSH and review the error log below, then follow the recovery tips.
|
||||
</p>
|
||||
</div>
|
||||
<button id="firstbootErrorClose" class="ghost icon-btn close-btn" title="Close setup error">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="control-actions wrap gap">
|
||||
<button id="firstbootCopyError" class="ghost">Copy error log</button>
|
||||
<button id="firstbootShowRecovery" class="ghost">Show recovery steps</button>
|
||||
</div>
|
||||
<div id="firstbootRecovery" class="help-body hidden">
|
||||
<ul>
|
||||
<li>SSH: <code>ssh dietpi@pikit</code></li>
|
||||
<li>Error log: <code>sudo cat /var/lib/pikit/firstboot/firstboot.error</code></li>
|
||||
<li>Full log: <code>sudo cat /var/lib/pikit/firstboot/firstboot.log</code></li>
|
||||
<li>If needed: <code>sudo systemctl restart nginx pikit-api</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<pre id="firstbootErrorLog" class="log-box" aria-live="polite"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
Download Pi-Kit CA
|
||||
</a>
|
||||
</div>
|
||||
<p class="checksum">SHA256: <code class="inline">6bc217c340e502ef20117bd4dc35e05f9f16c562cc3a236d3831a9947caddb97</code></p>
|
||||
<p class="checksum">SHA256: <code id="caHash" class="inline">Loading...</code></p>
|
||||
<details>
|
||||
<summary id="win">Windows</summary>
|
||||
<p>Run <strong>mmc</strong> → Add/Remove Snap-in → Certificates (Computer) → Trusted Root CAs → Import <em>pikit-ca.crt</em>.</p>
|
||||
@@ -85,10 +85,39 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const target = `https://${location.hostname}`;
|
||||
const host = location.hostname || "pikit.local";
|
||||
const target = `https://${host}`;
|
||||
const hasCookie = document.cookie.includes("pikit_https_ok=1");
|
||||
const statusChip = document.getElementById("statusChip");
|
||||
const copyStatus = document.getElementById("copyStatus");
|
||||
const downloadCa = document.getElementById("downloadCa");
|
||||
const caHash = document.getElementById("caHash");
|
||||
const caUrl = `http://${host}/assets/pikit-ca.crt`;
|
||||
|
||||
if (downloadCa) downloadCa.href = caUrl;
|
||||
|
||||
const cmdTemplates = {
|
||||
archCmd: `curl -s ${caUrl} -o /tmp/pikit-ca.crt && sudo install -m644 /tmp/pikit-ca.crt /etc/ca-certificates/trust-source/anchors/ && sudo trust extract-compat`,
|
||||
debCmd: `curl -s ${caUrl} -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /usr/local/share/ca-certificates/pikit-ca.crt && sudo update-ca-certificates`,
|
||||
fedoraCmd: `curl -s ${caUrl} -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /etc/pki/ca-trust/source/anchors/pikit-ca.crt && sudo update-ca-trust`,
|
||||
bsdCmd: `fetch -o /tmp/pikit-ca.crt ${caUrl} && sudo install -m644 /tmp/pikit-ca.crt /usr/local/share/certs/pikit-ca.crt && sudo certctl rehash`,
|
||||
};
|
||||
|
||||
Object.entries(cmdTemplates).forEach(([id, cmd]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = cmd;
|
||||
});
|
||||
|
||||
async function loadCaHash() {
|
||||
if (!caHash) return;
|
||||
try {
|
||||
const res = await fetch("/api/firstboot");
|
||||
const data = await res.json();
|
||||
caHash.textContent = data?.ca_hash || "Unavailable";
|
||||
} catch (err) {
|
||||
caHash.textContent = "Unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("continueBtn").addEventListener("click", () => {
|
||||
window.location = target;
|
||||
@@ -147,6 +176,8 @@
|
||||
} else {
|
||||
statusChip.textContent = "You’re on HTTP — trust the CA or click Go to secure dashboard.";
|
||||
}
|
||||
|
||||
loadCaHash();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user