Refactor releases UI into module; cap update log height
This commit is contained in:
@@ -1,19 +1,11 @@
|
|||||||
// Entry point for the dashboard: wires UI events, pulls status, and initializes
|
// Entry point for the dashboard: wires UI events, pulls status, and initializes
|
||||||
// feature modules (services, settings, stats).
|
// feature modules (services, settings, stats).
|
||||||
import {
|
import { getStatus, triggerReset } from "./api.js";
|
||||||
getStatus,
|
|
||||||
triggerReset,
|
|
||||||
getReleaseStatus,
|
|
||||||
checkRelease,
|
|
||||||
applyRelease,
|
|
||||||
rollbackRelease,
|
|
||||||
setReleaseAutoCheck,
|
|
||||||
setReleaseChannel,
|
|
||||||
} from "./api.js";
|
|
||||||
import { placeholderStatus, renderStats } from "./status.js";
|
import { placeholderStatus, renderStats } from "./status.js";
|
||||||
import { initServiceControls, renderServices } from "./services.js";
|
import { initServiceControls, renderServices } from "./services.js";
|
||||||
import { initSettings } from "./settings.js";
|
import { initSettings } from "./settings.js";
|
||||||
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
|
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
|
||||||
|
import { initReleaseUI } from "./releases.js?v=20251213f";
|
||||||
|
|
||||||
const servicesGrid = document.getElementById("servicesGrid");
|
const servicesGrid = document.getElementById("servicesGrid");
|
||||||
const heroStats = document.getElementById("heroStats");
|
const heroStats = document.getElementById("heroStats");
|
||||||
@@ -84,25 +76,6 @@ const menuClose = document.getElementById("menuClose");
|
|||||||
const advBtn = document.getElementById("advBtn");
|
const advBtn = document.getElementById("advBtn");
|
||||||
const advModal = document.getElementById("advModal");
|
const advModal = document.getElementById("advModal");
|
||||||
const advClose = document.getElementById("advClose");
|
const advClose = document.getElementById("advClose");
|
||||||
const releaseBtn = document.getElementById("releaseBtn");
|
|
||||||
const releaseModal = document.getElementById("releaseModal");
|
|
||||||
const releaseClose = document.getElementById("releaseClose");
|
|
||||||
const releaseCurrent = document.getElementById("releaseCurrent");
|
|
||||||
const releaseLatest = document.getElementById("releaseLatest");
|
|
||||||
const releaseStatusMsg = document.getElementById("releaseStatusMsg");
|
|
||||||
const releaseProgress = document.getElementById("releaseProgress");
|
|
||||||
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 helpBtn = document.getElementById("helpBtn");
|
||||||
const helpModal = document.getElementById("helpModal");
|
const helpModal = document.getElementById("helpModal");
|
||||||
@@ -146,18 +119,7 @@ let toastAnimation = "slide-in";
|
|||||||
let toastDurationMs = 5000;
|
let toastDurationMs = 5000;
|
||||||
let toastSpeedMs = 300;
|
let toastSpeedMs = 300;
|
||||||
let fontChoice = "redhat";
|
let fontChoice = "redhat";
|
||||||
let releaseBusyActive = false;
|
let releaseUI = null;
|
||||||
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() {
|
function applyToastSettings() {
|
||||||
if (!toastContainer) return;
|
if (!toastContainer) return;
|
||||||
@@ -380,7 +342,7 @@ async function loadStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pull Pi-Kit release status after core status
|
// Pull Pi-Kit release status after core status
|
||||||
loadReleaseStatus();
|
releaseUI?.refreshStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
renderStats(heroStats, placeholderStatus);
|
renderStats(heroStats, placeholderStatus);
|
||||||
@@ -417,114 +379,6 @@ function updatesFlagEl(enabled) {
|
|||||||
if (enabled === false) updatesFlagTop.classList.add("chip-off");
|
if (enabled === false) updatesFlagTop.classList.add("chip-off");
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
|
||||||
const {
|
|
||||||
current_version = "n/a",
|
|
||||||
latest_version = "n/a",
|
|
||||||
status = "unknown",
|
|
||||||
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;
|
|
||||||
if (releaseStatusMsg) {
|
|
||||||
releaseStatusMsg.textContent =
|
|
||||||
status === "update_available"
|
|
||||||
? message || "Update available"
|
|
||||||
: status === "up_to_date"
|
|
||||||
? "Up to date"
|
|
||||||
: message || status;
|
|
||||||
releaseStatusMsg.classList.toggle("error", status === "error");
|
|
||||||
}
|
|
||||||
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
|
|
||||||
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" });
|
|
||||||
if (releaseStatusMsg) {
|
|
||||||
releaseStatusMsg.textContent = "Failed to load release status";
|
|
||||||
releaseStatusMsg.classList.add("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setReleaseChip(state) {
|
|
||||||
if (!releaseFlagTop) return;
|
|
||||||
releaseFlagTop.textContent = "Pi-Kit: n/a";
|
|
||||||
releaseFlagTop.className = "status-chip quiet";
|
|
||||||
if (!state) return;
|
|
||||||
const { status, latest_version, current_version, message } = state;
|
|
||||||
const label =
|
|
||||||
status === "update_available"
|
|
||||||
? `Update → ${latest_version || "new"}`
|
|
||||||
: status === "up_to_date"
|
|
||||||
? `Pi-Kit: ${current_version || "latest"}`
|
|
||||||
: status === "checking"
|
|
||||||
? "Checking…"
|
|
||||||
: status === "error"
|
|
||||||
? "Update error"
|
|
||||||
: `Pi-Kit: ${current_version || "n/a"}`;
|
|
||||||
releaseFlagTop.textContent = label;
|
|
||||||
if (status === "update_available") releaseFlagTop.classList.add("chip-warm");
|
|
||||||
if (status === "error") releaseFlagTop.classList.add("chip-off");
|
|
||||||
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() {
|
function wireModals() {
|
||||||
advBtn.onclick = () => advModal.classList.remove("hidden");
|
advBtn.onclick = () => advModal.classList.remove("hidden");
|
||||||
advClose.onclick = () => advModal.classList.add("hidden");
|
advClose.onclick = () => advModal.classList.add("hidden");
|
||||||
@@ -538,177 +392,6 @@ function wireModals() {
|
|||||||
addServiceModal?.addEventListener("click", (e) => {
|
addServiceModal?.addEventListener("click", (e) => {
|
||||||
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
||||||
});
|
});
|
||||||
releaseBtn?.addEventListener("click", () => {
|
|
||||||
releaseModal?.classList.remove("hidden");
|
|
||||||
loadReleaseStatus();
|
|
||||||
});
|
|
||||||
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
|
|
||||||
releaseModal?.addEventListener("click", (e) => {
|
|
||||||
if (e.target === releaseModal) releaseModal.classList.add("hidden");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireReleaseControls() {
|
|
||||||
releaseCheckBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
if (releaseProgress) releaseProgress.textContent = "Checking for updates…";
|
|
||||||
logRelease("Checking for updates…");
|
|
||||||
await checkRelease();
|
|
||||||
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 = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
releaseAutoCheck?.addEventListener("change", async () => {
|
|
||||||
try {
|
|
||||||
await setReleaseAutoCheck(releaseAutoCheck.checked);
|
|
||||||
showToast("Auto-check preference saved", "success");
|
|
||||||
} catch (e) {
|
|
||||||
showToast(e.error || "Failed to save preference", "error");
|
|
||||||
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(true);
|
|
||||||
const state = window.__lastReleaseState || {};
|
|
||||||
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(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"}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showBusy(title = "Working…", text = "This may take a few seconds.") {
|
function showBusy(title = "Working…", text = "This may take a few seconds.") {
|
||||||
@@ -723,47 +406,6 @@ function hideBusy() {
|
|||||||
busyOverlay?.classList.add("hidden");
|
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) {
|
function confirmAction(title, body) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!confirmModal) {
|
if (!confirmModal) {
|
||||||
@@ -846,9 +488,14 @@ if (typeof window !== "undefined") {
|
|||||||
function main() {
|
function main() {
|
||||||
applyTooltips();
|
applyTooltips();
|
||||||
wireModals();
|
wireModals();
|
||||||
wireReleaseControls();
|
|
||||||
wireResetAndUpdates();
|
wireResetAndUpdates();
|
||||||
wireAccordions();
|
wireAccordions();
|
||||||
|
releaseUI = initReleaseUI({
|
||||||
|
showToast,
|
||||||
|
showBusy,
|
||||||
|
hideBusy,
|
||||||
|
confirmAction,
|
||||||
|
});
|
||||||
loadToastSettings();
|
loadToastSettings();
|
||||||
|
|
||||||
if (advClose) {
|
if (advClose) {
|
||||||
|
|||||||
363
pikit-web/assets/releases.js
Normal file
363
pikit-web/assets/releases.js
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
// Release / updater UI controller
|
||||||
|
// Handles checking, applying, rollback, channel toggle, changelog modal, and log rendering.
|
||||||
|
|
||||||
|
import {
|
||||||
|
getReleaseStatus,
|
||||||
|
checkRelease,
|
||||||
|
applyRelease,
|
||||||
|
rollbackRelease,
|
||||||
|
setReleaseAutoCheck,
|
||||||
|
setReleaseChannel,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
function shorten(text, max = 90) {
|
||||||
|
if (!text || typeof text !== "string") return text;
|
||||||
|
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction }) {
|
||||||
|
const releaseFlagTop = document.getElementById("releaseFlagTop");
|
||||||
|
const releaseBtn = document.getElementById("releaseBtn");
|
||||||
|
const releaseModal = document.getElementById("releaseModal");
|
||||||
|
const releaseClose = document.getElementById("releaseClose");
|
||||||
|
const releaseCurrent = document.getElementById("releaseCurrent");
|
||||||
|
const releaseLatest = document.getElementById("releaseLatest");
|
||||||
|
const releaseStatusMsg = document.getElementById("releaseStatusMsg");
|
||||||
|
const releaseProgress = document.getElementById("releaseProgress");
|
||||||
|
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");
|
||||||
|
const releaseChangelogBtn = window.__pikitReleaseChangelogBtn || document.getElementById("releaseChangelogBtn");
|
||||||
|
const releaseChannelToggle = window.__pikitReleaseChannelToggle || document.getElementById("releaseChannelToggle");
|
||||||
|
window.__pikitReleaseChangelogBtn = releaseChangelogBtn;
|
||||||
|
window.__pikitReleaseChannelToggle = releaseChannelToggle;
|
||||||
|
|
||||||
|
const changelogModal = document.getElementById("changelogModal");
|
||||||
|
const changelogTitle = document.getElementById("changelogTitle");
|
||||||
|
const changelogBody = document.getElementById("changelogBody");
|
||||||
|
const changelogClose = document.getElementById("changelogClose");
|
||||||
|
|
||||||
|
let releaseBusyActive = false;
|
||||||
|
let releaseLogLines = [];
|
||||||
|
let releaseLastFetched = 0;
|
||||||
|
let lastReleaseLogKey = "";
|
||||||
|
let changelogCache = { version: null, text: "" };
|
||||||
|
let lastChangelogUrl = null;
|
||||||
|
let releaseChannel = "dev";
|
||||||
|
|
||||||
|
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, 120);
|
||||||
|
if (releaseLog) {
|
||||||
|
releaseLog.textContent = releaseLogLines.join("\n");
|
||||||
|
releaseLog.scrollTop = 0; // keep most recent in view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReleaseChip(state) {
|
||||||
|
if (!releaseFlagTop) return;
|
||||||
|
releaseFlagTop.textContent = "Pi-Kit: n/a";
|
||||||
|
releaseFlagTop.className = "status-chip quiet";
|
||||||
|
if (!state) return;
|
||||||
|
const { status, latest_version, current_version, message } = state;
|
||||||
|
const label =
|
||||||
|
status === "update_available"
|
||||||
|
? `Update → ${latest_version || "new"}`
|
||||||
|
: status === "up_to_date"
|
||||||
|
? `Pi-Kit: ${current_version || "latest"}`
|
||||||
|
: status === "checking"
|
||||||
|
? "Checking…"
|
||||||
|
: status === "error"
|
||||||
|
? "Update error"
|
||||||
|
: `Pi-Kit: ${current_version || "n/a"}`;
|
||||||
|
releaseFlagTop.textContent = label;
|
||||||
|
if (status === "update_available") releaseFlagTop.classList.add("chip-warm");
|
||||||
|
if (status === "error") releaseFlagTop.classList.add("chip-off");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
releaseModal?.classList.add("hidden");
|
||||||
|
changelogModal.classList.remove("hidden");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Changelog fetch failed", e);
|
||||||
|
showToast("Failed to load changelog", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
const {
|
||||||
|
current_version = "n/a",
|
||||||
|
latest_version = "n/a",
|
||||||
|
status = "unknown",
|
||||||
|
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;
|
||||||
|
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
|
||||||
|
if (releaseProgress) releaseProgress.textContent = progress || "";
|
||||||
|
if (status === "in_progress" && progress) {
|
||||||
|
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" });
|
||||||
|
if (releaseStatusMsg) {
|
||||||
|
releaseStatusMsg.textContent = "Failed to load release status";
|
||||||
|
releaseStatusMsg.classList.add("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollReleaseStatus() {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 30; // ~1 min at 1s
|
||||||
|
const started = Date.now();
|
||||||
|
const minWaitMs = 3000;
|
||||||
|
const tick = async () => {
|
||||||
|
attempts += 1;
|
||||||
|
await loadReleaseStatus(true);
|
||||||
|
const state = window.__lastReleaseState || {};
|
||||||
|
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(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"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireReleaseControls() {
|
||||||
|
releaseBtn?.addEventListener("click", () => {
|
||||||
|
releaseModal?.classList.remove("hidden");
|
||||||
|
loadReleaseStatus(true);
|
||||||
|
});
|
||||||
|
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
|
||||||
|
releaseModal?.addEventListener("click", (e) => {
|
||||||
|
if (e.target === releaseModal) releaseModal.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
releaseCheckBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
if (releaseProgress) releaseProgress.textContent = "Checking for updates…";
|
||||||
|
logRelease("Checking for updates…");
|
||||||
|
await checkRelease();
|
||||||
|
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 = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
releaseAutoCheck?.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
await setReleaseAutoCheck(releaseAutoCheck.checked);
|
||||||
|
showToast("Auto-check preference saved", "success");
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.error || "Failed to save preference", "error");
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wireReleaseControls();
|
||||||
|
|
||||||
|
return {
|
||||||
|
refreshStatus: (force = false) => loadReleaseStatus(force),
|
||||||
|
logRelease,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -417,6 +417,10 @@ body {
|
|||||||
.modal-card.wide pre.log-box {
|
.modal-card.wide pre.log-box {
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
}
|
}
|
||||||
|
#releaseModal pre.log-box {
|
||||||
|
max-height: 220px !important;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
:root[data-theme="light"] .log-box {
|
:root[data-theme="light"] .log-box {
|
||||||
background: rgba(12, 18, 32, 0.04);
|
background: rgba(12, 18, 32, 0.04);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -726,7 +726,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="assets/main.js?v=20251213d"></script>
|
<script type="module" src="assets/main.js?v=20251213f"></script>
|
||||||
<div id="toastContainer" class="toast-container"></div>
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user