From 4461613339d8a3cb6f2e933d44b80a15718e45ee Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 12 Dec 2025 21:06:40 -0500 Subject: [PATCH] Updater: channel-aware apply, UI polish, cache-bust --- .gitignore | 3 + pikit-api.py | 311 ++++++++++++++++++++++++++++++------- pikit-web/assets/api.js | 5 + pikit-web/assets/main.js | 251 ++++++++++++++++++++++++++++-- pikit-web/assets/style.css | 108 ++++++++++++- pikit-web/index.html | 56 ++++++- 6 files changed, 661 insertions(+), 73 deletions(-) diff --git a/.gitignore b/.gitignore index 08aec77..a2438d7 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ out/ # Stock images (large) images/stock/ + +# Local helpers +set_ready.sh diff --git a/pikit-api.py b/pikit-api.py index 0cb161f..ac2ba0d 100644 --- a/pikit-api.py +++ b/pikit-api.py @@ -4,8 +4,10 @@ from http.server import BaseHTTPRequestHandler, HTTPServer import re import urllib.request import hashlib +import urllib.parse import fcntl from functools import partial +import json as jsonlib HOST = "127.0.0.1" PORT = 4000 @@ -518,6 +520,7 @@ def load_update_state(): "auto_check": False, "in_progress": False, "progress": None, + "channel": os.environ.get("PIKIT_CHANNEL", "dev"), } @@ -526,27 +529,146 @@ def save_update_state(state: dict): UPDATE_STATE.write_text(json.dumps(state, indent=2)) +def _auth_token(): + return os.environ.get("PIKIT_AUTH_TOKEN") or AUTH_TOKEN + + +def _gitea_latest_manifest(target: str): + """ + Fallback: when a manifest URL 404s, try hitting the Gitea API to grab the + latest release asset named manifest.json. + """ + try: + # target like https://host/owner/repo/releases/download/vX/manifest.json + parts = target.split("/") + if "releases" not in parts: + return None + idx = parts.index("releases") + if idx < 2: + return None + base = "/".join(parts[:3]) # scheme + host + owner = parts[idx - 2] + repo = parts[idx - 1] + api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases/latest" + req = urllib.request.Request(api_url) + token = _auth_token() + if token: + req.add_header("Authorization", f"token {token}") + resp = urllib.request.urlopen(req, timeout=10) + rel = json.loads(resp.read().decode()) + assets = rel.get("assets") or [] + manifest_asset = next((a for a in assets if a.get("name") == "manifest.json"), None) + if manifest_asset and manifest_asset.get("browser_download_url"): + # Download that manifest + return fetch_manifest(manifest_asset["browser_download_url"]) + except Exception: + return None + return None + + def fetch_manifest(url: str = None): - target = url or DEFAULT_MANIFEST_URL + target = url or os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL req = urllib.request.Request(target) - if AUTH_TOKEN: - req.add_header("Authorization", f"token {AUTH_TOKEN}") - resp = urllib.request.urlopen(req, timeout=10) - data = resp.read() - manifest = json.loads(data.decode()) - return manifest + token = _auth_token() + if token: + req.add_header("Authorization", f"token {token}") + try: + resp = urllib.request.urlopen(req, timeout=10) + data = resp.read() + return json.loads(data.decode()) + except urllib.error.HTTPError as e: + if e.code == 404: + alt = _gitea_latest_manifest(target) + if alt: + return alt + raise + + +def fetch_manifest_for_channel(channel: str): + """ + For stable: use normal manifest (latest non-prerelease). + For dev: try normal manifest; if it points to stable, fetch latest prerelease manifest via Gitea API. + """ + channel = channel or "dev" + base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL + manifest = None + try: + manifest = fetch_manifest(base_manifest_url) + except Exception: + manifest = None + + # If we already have a manifest and channel is stable, return it + if manifest and channel == "stable": + return manifest + # If dev channel and manifest is dev, return it + if manifest: + version = manifest.get("version") or manifest.get("latest_version") + if channel == "dev" and version and "dev" in str(version): + return manifest + + # Try Gitea API for latest release (include prerelease) + try: + parts = base_manifest_url.split("/") + if "releases" not in parts: + if manifest: + return manifest + return fetch_manifest(base_manifest_url) + idx = parts.index("releases") + owner = parts[idx - 2] + repo = parts[idx - 1] + base = "/".join(parts[:3]) + api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases" + req = urllib.request.Request(api_url) + token = _auth_token() + if token: + req.add_header("Authorization", f"token {token}") + resp = urllib.request.urlopen(req, timeout=10) + releases = json.loads(resp.read().decode()) + + def pick(predicate): + for r in releases: + if predicate(r): + asset = next((a for a in r.get("assets", []) if a.get("name") == "manifest.json"), None) + if asset and asset.get("browser_download_url"): + return fetch_manifest(asset["browser_download_url"]) + return None + + if channel == "dev": + m = pick(lambda r: r.get("prerelease") is True) + if m: + return m + m = pick(lambda r: r.get("prerelease") is False) + if m: + return m + except Exception: + pass + + # last resort: return whatever manifest we had + if manifest: + return manifest + raise RuntimeError("No manifest found for channel") def download_file(url: str, dest: pathlib.Path): ensure_dir(dest.parent) req = urllib.request.Request(url) - if AUTH_TOKEN: - req.add_header("Authorization", f"token {AUTH_TOKEN}") + token = _auth_token() + if token: + req.add_header("Authorization", f"token {token}") with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f: shutil.copyfileobj(resp, f) return dest +def fetch_text_with_auth(url: str): + req = urllib.request.Request(url) + token = _auth_token() + if token: + req.add_header("Authorization", f"token {token}") + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.read().decode() + + def check_for_update(): state = load_update_state() lock = acquire_lock() @@ -559,16 +681,21 @@ def check_for_update(): state["progress"] = "Checking for updates…" save_update_state(state) try: - manifest = fetch_manifest() + manifest = fetch_manifest_for_channel(state.get("channel") or "dev") latest = manifest.get("version") or manifest.get("latest_version") state["latest_version"] = latest state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z" - if latest and latest != state.get("current_version"): - state["status"] = "update_available" - state["message"] = manifest.get("changelog", "Update available") - else: + channel = state.get("channel") or "dev" + if channel == "stable" and latest and "dev" in str(latest): state["status"] = "up_to_date" - state["message"] = "Up to date" + state["message"] = "Dev release available; enable dev channel to install." + else: + if latest and latest != state.get("current_version"): + state["status"] = "update_available" + state["message"] = manifest.get("changelog", "Update available") + else: + state["status"] = "up_to_date" + state["message"] = "Up to date" except Exception as e: state["status"] = "up_to_date" state["message"] = f"Could not reach update server: {e}" @@ -604,11 +731,27 @@ def apply_update_stub(): save_update_state(state) try: - manifest = fetch_manifest() + channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev") + manifest = fetch_manifest_for_channel(channel) latest = manifest.get("version") or manifest.get("latest_version") if not latest: raise RuntimeError("Manifest missing version") + # Backup current BEFORE download/install to guarantee rollback point + ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S") + backup_dir = BACKUP_ROOT / ts + ensure_dir(backup_dir) + # Backup web and api + if WEB_ROOT.exists(): + ensure_dir(backup_dir / "pikit-web") + shutil.copytree(WEB_ROOT, backup_dir / "pikit-web", dirs_exist_ok=True) + if API_PATH.exists(): + shutil.copy2(API_PATH, backup_dir / "pikit-api.py") + if VERSION_FILE.exists(): + shutil.copy2(VERSION_FILE, backup_dir / "version.txt") + + prune_backups(keep=1) + # Paths bundle_url = manifest.get("bundle") or manifest.get("url") if not bundle_url: @@ -638,19 +781,6 @@ def apply_update_stub(): with tarfile.open(bundle_path, "r:gz") as tar: tar.extractall(stage_dir) - # Backup current - ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S") - backup_dir = BACKUP_ROOT / ts - ensure_dir(backup_dir) - # Backup web and api - if WEB_ROOT.exists(): - ensure_dir(backup_dir / "pikit-web") - shutil.copytree(WEB_ROOT, backup_dir / "pikit-web", dirs_exist_ok=True) - if API_PATH.exists(): - shutil.copy2(API_PATH, backup_dir / "pikit-api.py") - - prune_backups(keep=2) - # Deploy from staging staged_web = stage_dir / "pikit-web" if staged_web.exists(): @@ -683,19 +813,12 @@ def apply_update_stub(): state["progress"] = None save_update_state(state) # Attempt rollback if backup exists - backups = sorted(BACKUP_ROOT.glob("*"), reverse=True) - if backups: + backup = choose_rollback_backup() + if backup: try: - latest_backup = backups[0] - if (latest_backup / "pikit-web").exists(): - shutil.rmtree(WEB_ROOT, ignore_errors=True) - shutil.copytree(latest_backup / "pikit-web", WEB_ROOT) - if (latest_backup / "pikit-api.py").exists(): - shutil.copy2(latest_backup / "pikit-api.py", API_PATH) - os.chmod(API_PATH, 0o755) - for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"): - subprocess.run(["systemctl", "restart", svc], check=False) - state["message"] += " (rolled back to previous backup)" + restore_backup(backup) + state["current_version"] = read_current_version() + state["message"] += f" (rolled back to backup {backup.name})" save_update_state(state) except Exception as re: state["message"] += f" (rollback failed: {re})" @@ -721,8 +844,8 @@ def rollback_update_stub(): state["status"] = "in_progress" state["progress"] = "Rolling back…" save_update_state(state) - backups = sorted(BACKUP_ROOT.glob("*"), reverse=True) - if not backups: + backup = choose_rollback_backup() + if not backup: state["status"] = "error" state["message"] = "No backup available to rollback." state["in_progress"] = False @@ -730,18 +853,14 @@ def rollback_update_stub(): save_update_state(state) release_lock(lock) return state - target = backups[0] try: - if (target / "pikit-web").exists(): - shutil.rmtree(WEB_ROOT, ignore_errors=True) - shutil.copytree(target / "pikit-web", WEB_ROOT, dirs_exist_ok=True) - if (target / "pikit-api.py").exists(): - shutil.copy2(target / "pikit-api.py", API_PATH) - os.chmod(API_PATH, 0o755) - for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"): - subprocess.run(["systemctl", "restart", svc], check=False) + restore_backup(backup) state["status"] = "up_to_date" - state["message"] = f"Rolled back to backup {target.name}" + state["current_version"] = read_current_version() + state["latest_version"] = state.get("latest_version") or state["current_version"] + ver = get_backup_version(backup) + suffix = f" (version {ver})" if ver else "" + state["message"] = f"Rolled back to backup {backup.name}{suffix}" except Exception as e: state["status"] = "error" state["message"] = f"Rollback failed: {e}" @@ -795,12 +914,70 @@ def release_lock(lockfile): def prune_backups(keep: int = 2): if keep < 1: keep = 1 - ensure_dir(BACKUP_ROOT) - backups = sorted([p for p in BACKUP_ROOT.iterdir() if p.is_dir()], reverse=True) + backups = list_backups() for old in backups[keep:]: shutil.rmtree(old, ignore_errors=True) +def list_backups(): + """Return backups sorted by mtime (newest first).""" + ensure_dir(BACKUP_ROOT) + backups = [p for p in BACKUP_ROOT.iterdir() if p.is_dir()] + backups.sort(key=lambda p: p.stat().st_mtime, reverse=True) + return backups + + +def get_backup_version(path: pathlib.Path): + vf = path / "version.txt" + if not vf.exists(): + web_version = path / "pikit-web" / "data" / "version.json" + if not web_version.exists(): + return None + try: + return json.loads(web_version.read_text()).get("version") + except Exception: + return None + try: + return vf.read_text().strip() + except Exception: + return None + + +def choose_rollback_backup(): + """ + Pick the most recent backup whose version differs from the currently + installed version. If none differ, fall back to the newest backup. + """ + backups = list_backups() + if not backups: + return None + current = read_current_version() + for b in backups: + ver = get_backup_version(b) + if ver and ver != current: + return b + return backups[0] + + +def restore_backup(target: pathlib.Path): + if (target / "pikit-web").exists(): + shutil.rmtree(WEB_ROOT, ignore_errors=True) + shutil.copytree(target / "pikit-web", WEB_ROOT, dirs_exist_ok=True) + if (target / "pikit-api.py").exists(): + shutil.copy2(target / "pikit-api.py", API_PATH) + os.chmod(API_PATH, 0o755) + VERSION_FILE.parent.mkdir(parents=True, exist_ok=True) + if (target / "version.txt").exists(): + shutil.copy2(target / "version.txt", VERSION_FILE) + else: + # Fall back to the version recorded in the web bundle + ver = get_backup_version(target) + if ver: + VERSION_FILE.write_text(str(ver)) + for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"): + subprocess.run(["systemctl", "restart", svc], check=False) + + class Handler(BaseHTTPRequestHandler): """Minimal JSON API for the dashboard (status, services, updates, reset).""" def _send(self, code, data): @@ -896,7 +1073,23 @@ class Handler(BaseHTTPRequestHandler): elif self.path.startswith("/api/update/status"): state = load_update_state() state["current_version"] = read_current_version() + state["channel"] = state.get("channel", os.environ.get("PIKIT_CHANNEL", "dev")) self._send(200, state) + elif self.path.startswith("/api/update/changelog"): + # Fetch changelog text (URL param ?url= overrides manifest changelog) + try: + qs = urllib.parse.urlparse(self.path).query + params = urllib.parse.parse_qs(qs) + url = params.get("url", [None])[0] + if not url: + manifest = fetch_manifest() + url = manifest.get("changelog") + if not url: + return self._send(404, {"error": "no changelog url"}) + text = fetch_text_with_auth(url) + return self._send(200, {"text": text}) + except Exception as e: + return self._send(500, {"error": str(e)}) else: self._send(404, {"error": "not found"}) @@ -948,6 +1141,14 @@ class Handler(BaseHTTPRequestHandler): state["auto_check"] = bool(payload.get("enable")) save_update_state(state) return self._send(200, state) + if self.path.startswith("/api/update/channel"): + chan = payload.get("channel", "dev") + if chan not in ("dev", "stable"): + return self._send(400, {"error": "channel must be dev or stable"}) + state = load_update_state() + state["channel"] = chan + save_update_state(state) + return self._send(200, state) if self.path.startswith("/api/services/add"): name = payload.get("name") port = int(payload.get("port", 0)) diff --git a/pikit-web/assets/api.js b/pikit-web/assets/api.js index 41cbafa..f0e1f80 100644 --- a/pikit-web/assets/api.js +++ b/pikit-web/assets/api.js @@ -62,6 +62,11 @@ export const setReleaseAutoCheck = (enable) => method: "POST", body: JSON.stringify({ enable }), }); +export const setReleaseChannel = (channel) => + api("/api/update/channel", { + method: "POST", + body: JSON.stringify({ channel }), + }); export const triggerReset = (confirm) => api("/api/reset", { diff --git a/pikit-web/assets/main.js b/pikit-web/assets/main.js index f919c69..bbc0930 100644 --- a/pikit-web/assets/main.js +++ b/pikit-web/assets/main.js @@ -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 || {}; diff --git a/pikit-web/assets/style.css b/pikit-web/assets/style.css index 0cb8234..a9acb20 100644 --- a/pikit-web/assets/style.css +++ b/pikit-web/assets/style.css @@ -326,6 +326,16 @@ body { border-color: rgba(225, 29, 72, 0.4); background: rgba(225, 29, 72, 0.08); } +.status-chip.chip-system { + color: #3b82f6; + border-color: rgba(59, 130, 246, 0.45); + background: rgba(59, 130, 246, 0.12); +} +.status-chip.chip-system { + color: #3b82f6; + border-color: rgba(59, 130, 246, 0.45); + background: rgba(59, 130, 246, 0.12); +} .status-chip.chip-warm { color: #d97706; border-color: rgba(217, 119, 6, 0.35); @@ -346,6 +356,11 @@ body { border-color: rgba(34, 197, 94, 0.5); color: #0f5132; } +:root[data-theme="light"] .status-chip.chip-system { + background: rgba(59, 130, 246, 0.16); + border-color: rgba(59, 130, 246, 0.55); + color: #153e9f; +} :root[data-theme="light"] .status-chip.chip-warm { background: rgba(217, 119, 6, 0.16); border-color: rgba(217, 119, 6, 0.5); @@ -356,6 +371,55 @@ body { border-color: rgba(225, 29, 72, 0.55); color: #7a1028; } +:root[data-theme="light"] .status-chip.chip-system { + background: rgba(59, 130, 246, 0.16); + border-color: rgba(59, 130, 246, 0.55); + color: #153e9f; +} +.log-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px; + margin-top: 12px; +} +.log-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} +.log-actions { + display: flex; + align-items: center; + gap: 8px; +} +.log-actions .icon-btn { + width: 32px; + height: 32px; + padding: 0; + font-size: 0.9rem; + background: var(--card-overlay); + border: 1px solid var(--border); +} +.log-box { + max-height: 140px; + overflow-y: auto; + background: rgba(0, 0, 0, 0.08); + border-radius: 6px; + padding: 10px; + font-family: "DM Sans", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9rem; + border: 1px dashed var(--border); + color: var(--muted); + white-space: pre-wrap; +} +.modal-card.wide pre.log-box { + max-height: 60vh; +} +:root[data-theme="light"] .log-box { + background: rgba(12, 18, 32, 0.04); +} .toast-container { position: fixed; bottom: 16px; @@ -1324,6 +1388,15 @@ select:focus-visible, transition: opacity 0.18s ease; z-index: 20; } +.modal#changelogModal { + z-index: 40; +} +.modal#changelogModal { + z-index: 40; +} +.modal#changelogModal { + z-index: 30; +} .modal.hidden { display: none; } @@ -1345,7 +1418,7 @@ select:focus-visible, max-height: 90vh; overflow-y: auto; position: relative; - padding: 0; + padding: 12px; } .modal-card { transform: translateY(6px) scale(0.99); @@ -1363,8 +1436,12 @@ select:focus-visible, border-bottom: 1px solid var(--border); } -.modal-card.wide .help-body { - padding: 0 18px 18px; +.modal-card.wide .help-body, +.modal-card.wide .controls { + padding: 0 12px 12px; +} +.modal-card.wide .control-card { + padding: 12px 14px; } /* Extra breathing room for custom add-service modal */ @@ -1379,6 +1456,31 @@ select:focus-visible, #releaseModal .modal-card.wide { max-width: 760px; } +.release-versions { + display: flex; + gap: 16px; + align-items: flex-start; + justify-content: space-between; +} +.release-versions > div { + flex: 1; + min-width: 0; +} +.release-versions .align-right { + text-align: right; +} +@media (max-width: 640px) { + .release-versions { + flex-direction: column; + gap: 8px; + } + .release-versions .align-right { + text-align: left; + } +} +.modal-card .status-msg { + overflow-wrap: anywhere; +} .modal:not(.hidden) .modal-card { transform: translateY(0) scale(1); } diff --git a/pikit-web/index.html b/pikit-web/index.html index dee9950..43efee4 100644 --- a/pikit-web/index.html +++ b/pikit-web/index.html @@ -87,6 +87,21 @@ + + + +