Updater: channel-aware apply, UI polish, cache-bust

This commit is contained in:
Aaron
2025-12-12 21:06:40 -05:00
parent e933fb325d
commit 4461613339
6 changed files with 661 additions and 73 deletions

View File

@@ -8,6 +8,7 @@ import {
applyRelease,
rollbackRelease,
setReleaseAutoCheck,
setReleaseChannel,
} from "./api.js";
import { placeholderStatus, renderStats } from "./status.js";
import { initServiceControls, renderServices } from "./services.js";
@@ -94,6 +95,14 @@ const releaseCheckBtn = document.getElementById("releaseCheckBtn");
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
const releaseRollbackBtn = document.getElementById("releaseRollbackBtn");
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
const releaseLog = document.getElementById("releaseLog");
const releaseLogStatus = document.getElementById("releaseLogStatus");
const releaseLogCopy = document.getElementById("releaseLogCopy");
// Guard against double-loading (cache/tunnel quirks)
const releaseChangelogBtn = window.__pikitReleaseChangelogBtn || document.getElementById("releaseChangelogBtn");
const releaseChannelToggle = window.__pikitReleaseChannelToggle || document.getElementById("releaseChannelToggle");
window.__pikitReleaseChangelogBtn = releaseChangelogBtn;
window.__pikitReleaseChannelToggle = releaseChannelToggle;
const helpBtn = document.getElementById("helpBtn");
const helpModal = document.getElementById("helpModal");
@@ -106,6 +115,15 @@ const busyTitle = document.getElementById("busyTitle");
const busyText = document.getElementById("busyText");
const toastContainer = document.getElementById("toastContainer");
const readyOverlay = document.getElementById("readyOverlay");
const confirmModal = document.getElementById("confirmModal");
const confirmTitle = document.getElementById("confirmTitle");
const confirmBody = document.getElementById("confirmBody");
const confirmOk = document.getElementById("confirmOk");
const confirmCancel = document.getElementById("confirmCancel");
const changelogModal = document.getElementById("changelogModal");
const changelogTitle = document.getElementById("changelogTitle");
const changelogBody = document.getElementById("changelogBody");
const changelogClose = document.getElementById("changelogClose");
const TOAST_POS_KEY = "pikit-toast-pos";
const TOAST_ANIM_KEY = "pikit-toast-anim";
@@ -128,6 +146,18 @@ let toastAnimation = "slide-in";
let toastDurationMs = 5000;
let toastSpeedMs = 300;
let fontChoice = "redhat";
let releaseBusyActive = false;
let releaseLogLines = [];
let lastReleaseLogKey = "";
let releaseLastFetched = 0;
let changelogCache = { version: null, text: "" };
let lastChangelogUrl = null;
let releaseChannel = "dev";
function shorten(text, max = 90) {
if (!text || typeof text !== "string") return text;
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
}
function applyToastSettings() {
if (!toastContainer) return;
@@ -379,14 +409,20 @@ function setTempFlag(tempC) {
function updatesFlagEl(enabled) {
if (!updatesFlagTop) return;
updatesFlagTop.textContent = "Auto updates";
updatesFlagTop.classList.remove("chip-on", "chip-off");
if (enabled === true) updatesFlagTop.classList.add("chip-on");
else if (enabled === false) updatesFlagTop.classList.add("chip-off");
const labelOn = "System updates: On";
const labelOff = "System updates: Off";
updatesFlagTop.textContent =
enabled === true ? labelOn : enabled === false ? labelOff : "System updates";
updatesFlagTop.className = "status-chip quiet chip-system";
if (enabled === false) updatesFlagTop.classList.add("chip-off");
}
async function loadReleaseStatus() {
async function loadReleaseStatus(force = false) {
if (!releaseFlagTop) return;
const now = Date.now();
if (!force && now - releaseLastFetched < 60000 && !releaseBusyActive) {
return;
}
setReleaseChip({ status: "checking" });
try {
const data = await getReleaseStatus();
@@ -397,8 +433,22 @@ async function loadReleaseStatus() {
message = "",
auto_check = false,
progress = null,
channel = "dev",
} = data || {};
releaseChannel = channel || "dev";
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
window.__lastReleaseState = data;
const key = [status, progress, message].join("|");
if (key !== lastReleaseLogKey) {
logRelease(`Status: ${status}${progress ? " • " + progress : ""}${message ? " • " + message : ""}`);
lastReleaseLogKey = key;
}
releaseLastFetched = now;
if (status === "update_available" && message && message.startsWith("http")) {
lastChangelogUrl = message;
} else if (latest_version) {
lastChangelogUrl = `https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v${latest_version}/CHANGELOG-${latest_version}.txt`;
}
setReleaseChip(data);
if (releaseCurrent) releaseCurrent.textContent = current_version;
if (releaseLatest) releaseLatest.textContent = latest_version;
@@ -415,6 +465,11 @@ async function loadReleaseStatus() {
if (releaseProgress) {
releaseProgress.textContent = progress ? progress : "";
}
if (status === "in_progress" && !releaseBusyActive) {
releaseBusyActive = true;
showBusy("Working on update…", progress || "This can take up to a minute.");
pollReleaseStatus();
}
} catch (e) {
console.error("Failed to load release status", e);
setReleaseChip({ status: "error", message: "Failed to load" });
@@ -444,7 +499,30 @@ function setReleaseChip(state) {
releaseFlagTop.textContent = label;
if (status === "update_available") releaseFlagTop.classList.add("chip-warm");
if (status === "error") releaseFlagTop.classList.add("chip-off");
releaseFlagTop.title = message || "Pi-Kit release status";
const msg = shorten(message, 80) || "";
releaseFlagTop.title = msg || "Pi-Kit release status";
if (releaseStatusMsg) {
releaseStatusMsg.textContent =
status === "update_available"
? msg || "Update available"
: status === "up_to_date"
? msg || "Up to date"
: msg || status;
}
if (releaseLogStatus) {
releaseLogStatus.textContent =
status === "in_progress"
? "Running…"
: status === "update_available"
? "Update available"
: status === "error"
? "Error"
: "Idle";
}
if (releaseChangelogBtn) {
releaseChangelogBtn.disabled = status === "checking";
releaseChangelogBtn.classList.toggle("ghost", status !== "update_available");
}
}
function wireModals() {
@@ -474,11 +552,19 @@ function wireReleaseControls() {
releaseCheckBtn?.addEventListener("click", async () => {
try {
if (releaseProgress) releaseProgress.textContent = "Checking for updates…";
logRelease("Checking for updates…");
await checkRelease();
await loadReleaseStatus();
await loadReleaseStatus(true);
const state = window.__lastReleaseState || {};
logRelease(
`Status: ${state.status || "unknown"}${
state.message ? " • " + state.message : ""
}`,
);
showToast("Checked for updates", "success");
} catch (e) {
showToast(e.error || "Check failed", "error");
logRelease(`Error: ${e.error || "Check failed"}`);
} finally {
if (releaseProgress) releaseProgress.textContent = "";
}
@@ -486,13 +572,32 @@ function wireReleaseControls() {
releaseApplyBtn?.addEventListener("click", async () => {
try {
const state = window.__lastReleaseState || {};
const { current_version, latest_version } = state;
const sameVersion =
current_version &&
latest_version &&
String(current_version) === String(latest_version);
if (sameVersion) {
const proceed = await confirmAction(
"Reinstall same version?",
`You are already on ${current_version}. Reinstall this version anyway?`,
);
if (!proceed) {
logRelease("Upgrade cancelled (same version).");
return;
}
}
releaseBusyActive = true;
showBusy("Updating Pi-Kit…", "Applying release. This can take up to a minute.");
if (releaseProgress) releaseProgress.textContent = "Downloading and installing…";
logRelease("Starting upgrade…");
await applyRelease();
pollReleaseStatus();
showToast("Update started", "success");
} catch (e) {
showToast(e.error || "Update failed", "error");
logRelease(`Error: ${e.error || "Update failed"}`);
} finally {
if (releaseProgress) releaseProgress.textContent = "";
}
@@ -500,13 +605,16 @@ function wireReleaseControls() {
releaseRollbackBtn?.addEventListener("click", async () => {
try {
releaseBusyActive = true;
showBusy("Rolling back…", "Restoring previous backup.");
if (releaseProgress) releaseProgress.textContent = "Rolling back…";
logRelease("Starting rollback…");
await rollbackRelease();
pollReleaseStatus();
showToast("Rollback started", "success");
} catch (e) {
showToast(e.error || "Rollback failed", "error");
logRelease(`Error: ${e.error || "Rollback failed"}`);
} finally {
if (releaseProgress) releaseProgress.textContent = "";
}
@@ -521,23 +629,82 @@ function wireReleaseControls() {
releaseAutoCheck.checked = !releaseAutoCheck.checked;
}
});
releaseChannelToggle?.addEventListener("change", async () => {
try {
const chan = releaseChannelToggle.checked ? "dev" : "stable";
await setReleaseChannel(chan);
releaseChannel = chan;
logRelease(`Channel set to ${chan}`);
await loadReleaseStatus(true);
} catch (e) {
showToast(e.error || "Failed to save channel", "error");
releaseChannelToggle.checked = releaseChannel === "dev";
}
});
releaseChangelogBtn?.addEventListener("click", async () => {
const state = window.__lastReleaseState || {};
const { latest_version, message } = state;
const url = (message && message.startsWith("http") ? message : null) || lastChangelogUrl;
if (!url) {
showToast("No changelog URL available", "error");
return;
}
await showChangelog(latest_version, url);
});
releaseLogCopy?.addEventListener("click", async () => {
try {
const text = releaseLogLines.join("\n") || "No log entries yet.";
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for non-HTTPS contexts
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("Log copied", "success");
} catch (e) {
console.error("Copy failed", e);
showToast("Could not copy log", "error");
}
});
changelogClose?.addEventListener("click", () => {
changelogModal?.classList.add("hidden");
releaseModal?.classList.remove("hidden");
});
}
function pollReleaseStatus() {
let attempts = 0;
const maxAttempts = 30; // ~1 min at 2s
const started = Date.now();
const minWaitMs = 3000;
const tick = async () => {
attempts += 1;
await loadReleaseStatus();
await loadReleaseStatus(true);
const state = window.__lastReleaseState || {};
if (state.status === "in_progress" && attempts < maxAttempts) {
setTimeout(tick, 2000);
const tooSoon = Date.now() - started < minWaitMs;
if ((state.status === "in_progress" || tooSoon) && attempts < maxAttempts) {
setTimeout(tick, 1000);
} else {
releaseBusyActive = false;
hideBusy();
if (releaseProgress) releaseProgress.textContent = "";
if (state.status === "up_to_date") {
showToast("Update complete", "success");
showToast(state.message || "Update complete", "success");
logRelease("Update complete");
} else if (state.status === "error") {
showToast(state.message || "Update failed", "error");
logRelease(`Error: ${state.message || "Update failed"}`);
}
}
};
@@ -556,6 +723,68 @@ function hideBusy() {
busyOverlay?.classList.add("hidden");
}
function logRelease(msg) {
if (!msg) return;
const ts = new Date().toLocaleTimeString();
const line = `${ts} ${msg}`;
if (releaseLogLines[0] === line) return;
releaseLogLines.unshift(line);
releaseLogLines = releaseLogLines.slice(0, 80);
if (releaseLog) releaseLog.textContent = releaseLogLines.join("\n");
}
async function fetchText(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`Fetch failed (${res.status})`);
return res.text();
}
async function showChangelog(version, url) {
if (!changelogBody || !changelogModal) {
window.open(url, "_blank");
return;
}
try {
if (changelogCache.version === version && changelogCache.text) {
changelogBody.textContent = changelogCache.text;
} else {
changelogBody.textContent = "Loading changelog…";
const res = await fetch(`/api/update/changelog?${url ? `url=${encodeURIComponent(url)}` : ""}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const text = data.text || "No changelog content.";
changelogCache = { version, text };
changelogBody.textContent = text;
}
changelogTitle.textContent = version ? `Changelog — ${version}` : "Changelog";
changelogModal.classList.remove("hidden");
} catch (e) {
console.error("Changelog fetch failed", e);
showToast("Failed to load changelog", "error");
}
}
function confirmAction(title, body) {
return new Promise((resolve) => {
if (!confirmModal) {
const ok = window.confirm(body || title || "Are you sure?");
resolve(ok);
return;
}
confirmTitle.textContent = title || "Are you sure?";
confirmBody.textContent = body || "";
confirmModal.classList.remove("hidden");
const done = (val) => {
confirmModal.classList.add("hidden");
resolve(val);
};
const okHandler = () => done(true);
const cancelHandler = () => done(false);
confirmOk.onclick = okHandler;
confirmCancel.onclick = cancelHandler;
});
}
// Testing hook
if (typeof window !== "undefined") {
window.__pikitTest = window.__pikitTest || {};