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

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ out/
# Stock images (large) # Stock images (large)
images/stock/ images/stock/
# Local helpers
set_ready.sh

View File

@@ -4,8 +4,10 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
import re import re
import urllib.request import urllib.request
import hashlib import hashlib
import urllib.parse
import fcntl import fcntl
from functools import partial from functools import partial
import json as jsonlib
HOST = "127.0.0.1" HOST = "127.0.0.1"
PORT = 4000 PORT = 4000
@@ -518,6 +520,7 @@ def load_update_state():
"auto_check": False, "auto_check": False,
"in_progress": False, "in_progress": False,
"progress": None, "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)) 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): 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) req = urllib.request.Request(target)
if AUTH_TOKEN: token = _auth_token()
req.add_header("Authorization", f"token {AUTH_TOKEN}") if token:
resp = urllib.request.urlopen(req, timeout=10) req.add_header("Authorization", f"token {token}")
data = resp.read() try:
manifest = json.loads(data.decode()) resp = urllib.request.urlopen(req, timeout=10)
return manifest 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): def download_file(url: str, dest: pathlib.Path):
ensure_dir(dest.parent) ensure_dir(dest.parent)
req = urllib.request.Request(url) req = urllib.request.Request(url)
if AUTH_TOKEN: token = _auth_token()
req.add_header("Authorization", f"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: with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
shutil.copyfileobj(resp, f) shutil.copyfileobj(resp, f)
return dest 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(): def check_for_update():
state = load_update_state() state = load_update_state()
lock = acquire_lock() lock = acquire_lock()
@@ -559,16 +681,21 @@ def check_for_update():
state["progress"] = "Checking for updates…" state["progress"] = "Checking for updates…"
save_update_state(state) save_update_state(state)
try: try:
manifest = fetch_manifest() manifest = fetch_manifest_for_channel(state.get("channel") or "dev")
latest = manifest.get("version") or manifest.get("latest_version") latest = manifest.get("version") or manifest.get("latest_version")
state["latest_version"] = latest state["latest_version"] = latest
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z" state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
if latest and latest != state.get("current_version"): channel = state.get("channel") or "dev"
state["status"] = "update_available" if channel == "stable" and latest and "dev" in str(latest):
state["message"] = manifest.get("changelog", "Update available")
else:
state["status"] = "up_to_date" 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: except Exception as e:
state["status"] = "up_to_date" state["status"] = "up_to_date"
state["message"] = f"Could not reach update server: {e}" state["message"] = f"Could not reach update server: {e}"
@@ -604,11 +731,27 @@ def apply_update_stub():
save_update_state(state) save_update_state(state)
try: 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") latest = manifest.get("version") or manifest.get("latest_version")
if not latest: if not latest:
raise RuntimeError("Manifest missing version") 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 # Paths
bundle_url = manifest.get("bundle") or manifest.get("url") bundle_url = manifest.get("bundle") or manifest.get("url")
if not bundle_url: if not bundle_url:
@@ -638,19 +781,6 @@ def apply_update_stub():
with tarfile.open(bundle_path, "r:gz") as tar: with tarfile.open(bundle_path, "r:gz") as tar:
tar.extractall(stage_dir) 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 # Deploy from staging
staged_web = stage_dir / "pikit-web" staged_web = stage_dir / "pikit-web"
if staged_web.exists(): if staged_web.exists():
@@ -683,19 +813,12 @@ def apply_update_stub():
state["progress"] = None state["progress"] = None
save_update_state(state) save_update_state(state)
# Attempt rollback if backup exists # Attempt rollback if backup exists
backups = sorted(BACKUP_ROOT.glob("*"), reverse=True) backup = choose_rollback_backup()
if backups: if backup:
try: try:
latest_backup = backups[0] restore_backup(backup)
if (latest_backup / "pikit-web").exists(): state["current_version"] = read_current_version()
shutil.rmtree(WEB_ROOT, ignore_errors=True) state["message"] += f" (rolled back to backup {backup.name})"
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)"
save_update_state(state) save_update_state(state)
except Exception as re: except Exception as re:
state["message"] += f" (rollback failed: {re})" state["message"] += f" (rollback failed: {re})"
@@ -721,8 +844,8 @@ def rollback_update_stub():
state["status"] = "in_progress" state["status"] = "in_progress"
state["progress"] = "Rolling back…" state["progress"] = "Rolling back…"
save_update_state(state) save_update_state(state)
backups = sorted(BACKUP_ROOT.glob("*"), reverse=True) backup = choose_rollback_backup()
if not backups: if not backup:
state["status"] = "error" state["status"] = "error"
state["message"] = "No backup available to rollback." state["message"] = "No backup available to rollback."
state["in_progress"] = False state["in_progress"] = False
@@ -730,18 +853,14 @@ def rollback_update_stub():
save_update_state(state) save_update_state(state)
release_lock(lock) release_lock(lock)
return state return state
target = backups[0]
try: try:
if (target / "pikit-web").exists(): restore_backup(backup)
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)
state["status"] = "up_to_date" 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: except Exception as e:
state["status"] = "error" state["status"] = "error"
state["message"] = f"Rollback failed: {e}" state["message"] = f"Rollback failed: {e}"
@@ -795,12 +914,70 @@ def release_lock(lockfile):
def prune_backups(keep: int = 2): def prune_backups(keep: int = 2):
if keep < 1: if keep < 1:
keep = 1 keep = 1
ensure_dir(BACKUP_ROOT) backups = list_backups()
backups = sorted([p for p in BACKUP_ROOT.iterdir() if p.is_dir()], reverse=True)
for old in backups[keep:]: for old in backups[keep:]:
shutil.rmtree(old, ignore_errors=True) 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): class Handler(BaseHTTPRequestHandler):
"""Minimal JSON API for the dashboard (status, services, updates, reset).""" """Minimal JSON API for the dashboard (status, services, updates, reset)."""
def _send(self, code, data): def _send(self, code, data):
@@ -896,7 +1073,23 @@ class Handler(BaseHTTPRequestHandler):
elif self.path.startswith("/api/update/status"): elif self.path.startswith("/api/update/status"):
state = load_update_state() state = load_update_state()
state["current_version"] = read_current_version() state["current_version"] = read_current_version()
state["channel"] = state.get("channel", os.environ.get("PIKIT_CHANNEL", "dev"))
self._send(200, state) 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: else:
self._send(404, {"error": "not found"}) self._send(404, {"error": "not found"})
@@ -948,6 +1141,14 @@ class Handler(BaseHTTPRequestHandler):
state["auto_check"] = bool(payload.get("enable")) state["auto_check"] = bool(payload.get("enable"))
save_update_state(state) save_update_state(state)
return self._send(200, 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"): if self.path.startswith("/api/services/add"):
name = payload.get("name") name = payload.get("name")
port = int(payload.get("port", 0)) port = int(payload.get("port", 0))

View File

@@ -62,6 +62,11 @@ export const setReleaseAutoCheck = (enable) =>
method: "POST", method: "POST",
body: JSON.stringify({ enable }), body: JSON.stringify({ enable }),
}); });
export const setReleaseChannel = (channel) =>
api("/api/update/channel", {
method: "POST",
body: JSON.stringify({ channel }),
});
export const triggerReset = (confirm) => export const triggerReset = (confirm) =>
api("/api/reset", { api("/api/reset", {

View File

@@ -8,6 +8,7 @@ import {
applyRelease, applyRelease,
rollbackRelease, rollbackRelease,
setReleaseAutoCheck, setReleaseAutoCheck,
setReleaseChannel,
} from "./api.js"; } 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";
@@ -94,6 +95,14 @@ const releaseCheckBtn = document.getElementById("releaseCheckBtn");
const releaseApplyBtn = document.getElementById("releaseApplyBtn"); const releaseApplyBtn = document.getElementById("releaseApplyBtn");
const releaseRollbackBtn = document.getElementById("releaseRollbackBtn"); const releaseRollbackBtn = document.getElementById("releaseRollbackBtn");
const releaseAutoCheck = document.getElementById("releaseAutoCheck"); 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");
@@ -106,6 +115,15 @@ const busyTitle = document.getElementById("busyTitle");
const busyText = document.getElementById("busyText"); const busyText = document.getElementById("busyText");
const toastContainer = document.getElementById("toastContainer"); const toastContainer = document.getElementById("toastContainer");
const readyOverlay = document.getElementById("readyOverlay"); 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_POS_KEY = "pikit-toast-pos";
const TOAST_ANIM_KEY = "pikit-toast-anim"; const TOAST_ANIM_KEY = "pikit-toast-anim";
@@ -128,6 +146,18 @@ 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 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;
@@ -379,14 +409,20 @@ function setTempFlag(tempC) {
function updatesFlagEl(enabled) { function updatesFlagEl(enabled) {
if (!updatesFlagTop) return; if (!updatesFlagTop) return;
updatesFlagTop.textContent = "Auto updates"; const labelOn = "System updates: On";
updatesFlagTop.classList.remove("chip-on", "chip-off"); const labelOff = "System updates: Off";
if (enabled === true) updatesFlagTop.classList.add("chip-on"); updatesFlagTop.textContent =
else if (enabled === false) updatesFlagTop.classList.add("chip-off"); 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; if (!releaseFlagTop) return;
const now = Date.now();
if (!force && now - releaseLastFetched < 60000 && !releaseBusyActive) {
return;
}
setReleaseChip({ status: "checking" }); setReleaseChip({ status: "checking" });
try { try {
const data = await getReleaseStatus(); const data = await getReleaseStatus();
@@ -397,8 +433,22 @@ async function loadReleaseStatus() {
message = "", message = "",
auto_check = false, auto_check = false,
progress = null, progress = null,
channel = "dev",
} = data || {}; } = data || {};
releaseChannel = channel || "dev";
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
window.__lastReleaseState = data; 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); setReleaseChip(data);
if (releaseCurrent) releaseCurrent.textContent = current_version; if (releaseCurrent) releaseCurrent.textContent = current_version;
if (releaseLatest) releaseLatest.textContent = latest_version; if (releaseLatest) releaseLatest.textContent = latest_version;
@@ -415,6 +465,11 @@ async function loadReleaseStatus() {
if (releaseProgress) { if (releaseProgress) {
releaseProgress.textContent = progress ? progress : ""; 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) { } catch (e) {
console.error("Failed to load release status", e); console.error("Failed to load release status", e);
setReleaseChip({ status: "error", message: "Failed to load" }); setReleaseChip({ status: "error", message: "Failed to load" });
@@ -444,7 +499,30 @@ function setReleaseChip(state) {
releaseFlagTop.textContent = label; releaseFlagTop.textContent = label;
if (status === "update_available") releaseFlagTop.classList.add("chip-warm"); if (status === "update_available") releaseFlagTop.classList.add("chip-warm");
if (status === "error") releaseFlagTop.classList.add("chip-off"); 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() { function wireModals() {
@@ -474,11 +552,19 @@ function wireReleaseControls() {
releaseCheckBtn?.addEventListener("click", async () => { releaseCheckBtn?.addEventListener("click", async () => {
try { try {
if (releaseProgress) releaseProgress.textContent = "Checking for updates…"; if (releaseProgress) releaseProgress.textContent = "Checking for updates…";
logRelease("Checking for updates…");
await checkRelease(); 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"); showToast("Checked for updates", "success");
} catch (e) { } catch (e) {
showToast(e.error || "Check failed", "error"); showToast(e.error || "Check failed", "error");
logRelease(`Error: ${e.error || "Check failed"}`);
} finally { } finally {
if (releaseProgress) releaseProgress.textContent = ""; if (releaseProgress) releaseProgress.textContent = "";
} }
@@ -486,13 +572,32 @@ function wireReleaseControls() {
releaseApplyBtn?.addEventListener("click", async () => { releaseApplyBtn?.addEventListener("click", async () => {
try { 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."); showBusy("Updating Pi-Kit…", "Applying release. This can take up to a minute.");
if (releaseProgress) releaseProgress.textContent = "Downloading and installing…"; if (releaseProgress) releaseProgress.textContent = "Downloading and installing…";
logRelease("Starting upgrade…");
await applyRelease(); await applyRelease();
pollReleaseStatus(); pollReleaseStatus();
showToast("Update started", "success"); showToast("Update started", "success");
} catch (e) { } catch (e) {
showToast(e.error || "Update failed", "error"); showToast(e.error || "Update failed", "error");
logRelease(`Error: ${e.error || "Update failed"}`);
} finally { } finally {
if (releaseProgress) releaseProgress.textContent = ""; if (releaseProgress) releaseProgress.textContent = "";
} }
@@ -500,13 +605,16 @@ function wireReleaseControls() {
releaseRollbackBtn?.addEventListener("click", async () => { releaseRollbackBtn?.addEventListener("click", async () => {
try { try {
releaseBusyActive = true;
showBusy("Rolling back…", "Restoring previous backup."); showBusy("Rolling back…", "Restoring previous backup.");
if (releaseProgress) releaseProgress.textContent = "Rolling back…"; if (releaseProgress) releaseProgress.textContent = "Rolling back…";
logRelease("Starting rollback…");
await rollbackRelease(); await rollbackRelease();
pollReleaseStatus(); pollReleaseStatus();
showToast("Rollback started", "success"); showToast("Rollback started", "success");
} catch (e) { } catch (e) {
showToast(e.error || "Rollback failed", "error"); showToast(e.error || "Rollback failed", "error");
logRelease(`Error: ${e.error || "Rollback failed"}`);
} finally { } finally {
if (releaseProgress) releaseProgress.textContent = ""; if (releaseProgress) releaseProgress.textContent = "";
} }
@@ -521,23 +629,82 @@ function wireReleaseControls() {
releaseAutoCheck.checked = !releaseAutoCheck.checked; 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() { function pollReleaseStatus() {
let attempts = 0; let attempts = 0;
const maxAttempts = 30; // ~1 min at 2s const maxAttempts = 30; // ~1 min at 2s
const started = Date.now();
const minWaitMs = 3000;
const tick = async () => { const tick = async () => {
attempts += 1; attempts += 1;
await loadReleaseStatus(); await loadReleaseStatus(true);
const state = window.__lastReleaseState || {}; const state = window.__lastReleaseState || {};
if (state.status === "in_progress" && attempts < maxAttempts) { const tooSoon = Date.now() - started < minWaitMs;
setTimeout(tick, 2000); if ((state.status === "in_progress" || tooSoon) && attempts < maxAttempts) {
setTimeout(tick, 1000);
} else { } else {
releaseBusyActive = false;
hideBusy(); hideBusy();
if (releaseProgress) releaseProgress.textContent = "";
if (state.status === "up_to_date") { if (state.status === "up_to_date") {
showToast("Update complete", "success"); showToast(state.message || "Update complete", "success");
logRelease("Update complete");
} else if (state.status === "error") { } else if (state.status === "error") {
showToast(state.message || "Update failed", "error"); showToast(state.message || "Update failed", "error");
logRelease(`Error: ${state.message || "Update failed"}`);
} }
} }
}; };
@@ -556,6 +723,68 @@ 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) {
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 // Testing hook
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.__pikitTest = window.__pikitTest || {}; window.__pikitTest = window.__pikitTest || {};

View File

@@ -326,6 +326,16 @@ body {
border-color: rgba(225, 29, 72, 0.4); border-color: rgba(225, 29, 72, 0.4);
background: rgba(225, 29, 72, 0.08); 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 { .status-chip.chip-warm {
color: #d97706; color: #d97706;
border-color: rgba(217, 119, 6, 0.35); border-color: rgba(217, 119, 6, 0.35);
@@ -346,6 +356,11 @@ body {
border-color: rgba(34, 197, 94, 0.5); border-color: rgba(34, 197, 94, 0.5);
color: #0f5132; 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 { :root[data-theme="light"] .status-chip.chip-warm {
background: rgba(217, 119, 6, 0.16); background: rgba(217, 119, 6, 0.16);
border-color: rgba(217, 119, 6, 0.5); border-color: rgba(217, 119, 6, 0.5);
@@ -356,6 +371,55 @@ body {
border-color: rgba(225, 29, 72, 0.55); border-color: rgba(225, 29, 72, 0.55);
color: #7a1028; 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 { .toast-container {
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;
@@ -1324,6 +1388,15 @@ select:focus-visible,
transition: opacity 0.18s ease; transition: opacity 0.18s ease;
z-index: 20; z-index: 20;
} }
.modal#changelogModal {
z-index: 40;
}
.modal#changelogModal {
z-index: 40;
}
.modal#changelogModal {
z-index: 30;
}
.modal.hidden { .modal.hidden {
display: none; display: none;
} }
@@ -1345,7 +1418,7 @@ select:focus-visible,
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
position: relative; position: relative;
padding: 0; padding: 12px;
} }
.modal-card { .modal-card {
transform: translateY(6px) scale(0.99); transform: translateY(6px) scale(0.99);
@@ -1363,8 +1436,12 @@ select:focus-visible,
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.modal-card.wide .help-body { .modal-card.wide .help-body,
padding: 0 18px 18px; .modal-card.wide .controls {
padding: 0 12px 12px;
}
.modal-card.wide .control-card {
padding: 12px 14px;
} }
/* Extra breathing room for custom add-service modal */ /* Extra breathing room for custom add-service modal */
@@ -1379,6 +1456,31 @@ select:focus-visible,
#releaseModal .modal-card.wide { #releaseModal .modal-card.wide {
max-width: 760px; 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 { .modal:not(.hidden) .modal-card {
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }

View File

@@ -87,6 +87,21 @@
</div> </div>
</div> </div>
<div id="changelogModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow">Changelog</p>
<h3 id="changelogTitle">Release notes</h3>
</div>
<button id="changelogClose" class="ghost icon-btn close-btn" title="Close changelog">
&times;
</button>
</div>
<pre id="changelogBody" class="log-box" aria-live="polite"></pre>
</div>
</div>
<div id="releaseModal" class="modal hidden"> <div id="releaseModal" class="modal hidden">
<div class="modal-card wide"> <div class="modal-card wide">
<div class="panel-header sticky"> <div class="panel-header sticky">
@@ -102,15 +117,16 @@
</button> </button>
</div> </div>
<div class="controls column"> <div class="controls column">
<div class="control-card"> <div class="control-card release-versions">
<div> <div>
<p class="hint quiet">Current version</p> <p class="hint quiet">Current version</p>
<h3 id="releaseCurrent">n/a</h3> <h3 id="releaseCurrent">n/a</h3>
</div> </div>
<div> <div class="align-right">
<p class="hint quiet">Latest available</p> <p class="hint quiet">Latest available</p>
<h3 id="releaseLatest"></h3> <h3 id="releaseLatest"></h3>
<p id="releaseStatusMsg" class="hint status-msg"></p> <p id="releaseStatusMsg" class="hint status-msg"></p>
<button id="releaseChangelogBtn" class="ghost small" title="View changelog">Changelog</button>
</div> </div>
</div> </div>
<div class="control-actions split-row"> <div class="control-actions split-row">
@@ -118,7 +134,7 @@
Check Check
</button> </button>
<button id="releaseApplyBtn" title="Download and install the latest release"> <button id="releaseApplyBtn" title="Download and install the latest release">
Download &amp; install Upgrade
</button> </button>
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup"> <button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
Rollback Rollback
@@ -127,8 +143,24 @@
<input type="checkbox" id="releaseAutoCheck" /> <input type="checkbox" id="releaseAutoCheck" />
<span>Auto-check daily</span> <span>Auto-check daily</span>
</label> </label>
<label class="checkbox-row inline">
<input type="checkbox" id="releaseChannelToggle" />
<span>Allow dev builds</span>
</label>
</div> </div>
<div id="releaseProgress" class="hint status-msg"></div> <div id="releaseProgress" class="hint status-msg"></div>
<div class="log-card">
<div class="log-header">
<span class="hint quiet">Update console</span>
<div class="log-actions">
<button id="releaseLogCopy" class="ghost icon-btn" title="Copy log" aria-label="Copy log">
</button>
<span id="releaseLogStatus" class="hint quiet"></span>
</div>
</div>
<pre id="releaseLog" class="log-box" aria-live="polite"></pre>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -182,6 +214,22 @@
</div> </div>
</div> </div>
<div id="confirmModal" class="modal hidden">
<div class="modal-card">
<div class="panel-header">
<div>
<p class="eyebrow">Confirm action</p>
<h3 id="confirmTitle">Are you sure?</h3>
<p id="confirmBody" class="hint"></p>
</div>
</div>
<div class="control-actions right">
<button id="confirmCancel" class="ghost">Cancel</button>
<button id="confirmOk">Continue</button>
</div>
</div>
</div>
<div id="addServiceModal" class="modal hidden"> <div id="addServiceModal" class="modal hidden">
<div class="modal-card wide"> <div class="modal-card wide">
<div class="panel-header sticky"> <div class="panel-header sticky">
@@ -678,7 +726,7 @@
</div> </div>
</div> </div>
<script type="module" src="assets/main.js"></script> <script type="module" src="assets/main.js?v=20251213d"></script>
<div id="toastContainer" class="toast-container"></div> <div id="toastContainer" class="toast-container"></div>
</body> </body>
</html> </html>