12 Commits

8 changed files with 862 additions and 239 deletions

3
.gitignore vendored
View File

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

View File

@@ -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}")
token = _auth_token()
if token:
req.add_header("Authorization", f"token {token}")
try:
resp = urllib.request.urlopen(req, timeout=10)
data = resp.read()
manifest = json.loads(data.decode())
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,10 +681,15 @@ 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"
channel = state.get("channel") or "dev"
if channel == "stable" and latest and "dev" in str(latest):
state["status"] = "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")
@@ -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))

View File

@@ -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", {

View File

@@ -1,18 +1,11 @@
// Entry point for the dashboard: wires UI events, pulls status, and initializes
// feature modules (services, settings, stats).
import {
getStatus,
triggerReset,
getReleaseStatus,
checkRelease,
applyRelease,
rollbackRelease,
setReleaseAutoCheck,
} from "./api.js";
import { getStatus, triggerReset } from "./api.js";
import { placeholderStatus, renderStats } from "./status.js";
import { initServiceControls, renderServices } from "./services.js";
import { initSettings } from "./settings.js";
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
import { initReleaseUI } from "./releases.js?v=20251213g";
const servicesGrid = document.getElementById("servicesGrid");
const heroStats = document.getElementById("heroStats");
@@ -83,17 +76,6 @@ const menuClose = document.getElementById("menuClose");
const advBtn = document.getElementById("advBtn");
const advModal = document.getElementById("advModal");
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 helpBtn = document.getElementById("helpBtn");
const helpModal = document.getElementById("helpModal");
@@ -106,6 +88,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 +119,7 @@ let toastAnimation = "slide-in";
let toastDurationMs = 5000;
let toastSpeedMs = 300;
let fontChoice = "redhat";
let releaseUI = null;
function applyToastSettings() {
if (!toastContainer) return;
@@ -350,7 +342,7 @@ async function loadStatus() {
}
}
// Pull Pi-Kit release status after core status
loadReleaseStatus();
releaseUI?.refreshStatus();
} catch (e) {
console.error(e);
renderStats(heroStats, placeholderStatus);
@@ -379,72 +371,12 @@ 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");
}
async function loadReleaseStatus() {
if (!releaseFlagTop) 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,
} = data || {};
window.__lastReleaseState = data;
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 : "";
}
} 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");
releaseFlagTop.title = message || "Pi-Kit release status";
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");
}
function wireModals() {
@@ -460,88 +392,6 @@ function wireModals() {
addServiceModal?.addEventListener("click", (e) => {
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…";
await checkRelease();
await loadReleaseStatus();
showToast("Checked for updates", "success");
} catch (e) {
showToast(e.error || "Check failed", "error");
} finally {
if (releaseProgress) releaseProgress.textContent = "";
}
});
releaseApplyBtn?.addEventListener("click", async () => {
try {
showBusy("Updating Pi-Kit…", "Applying release. This can take up to a minute.");
if (releaseProgress) releaseProgress.textContent = "Downloading and installing…";
await applyRelease();
pollReleaseStatus();
showToast("Update started", "success");
} catch (e) {
showToast(e.error || "Update failed", "error");
} finally {
if (releaseProgress) releaseProgress.textContent = "";
}
});
releaseRollbackBtn?.addEventListener("click", async () => {
try {
showBusy("Rolling back…", "Restoring previous backup.");
if (releaseProgress) releaseProgress.textContent = "Rolling back…";
await rollbackRelease();
pollReleaseStatus();
showToast("Rollback started", "success");
} catch (e) {
showToast(e.error || "Rollback failed", "error");
} 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;
}
});
}
function pollReleaseStatus() {
let attempts = 0;
const maxAttempts = 30; // ~1 min at 2s
const tick = async () => {
attempts += 1;
await loadReleaseStatus();
const state = window.__lastReleaseState || {};
if (state.status === "in_progress" && attempts < maxAttempts) {
setTimeout(tick, 2000);
} else {
hideBusy();
if (state.status === "up_to_date") {
showToast("Update complete", "success");
} else if (state.status === "error") {
showToast(state.message || "Update failed", "error");
}
}
};
tick();
}
function showBusy(title = "Working…", text = "This may take a few seconds.") {
@@ -556,6 +406,27 @@ function hideBusy() {
busyOverlay?.classList.add("hidden");
}
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 || {};
@@ -617,9 +488,14 @@ if (typeof window !== "undefined") {
function main() {
applyTooltips();
wireModals();
wireReleaseControls();
wireResetAndUpdates();
wireAccordions();
releaseUI = initReleaseUI({
showToast,
showBusy,
hideBusy,
confirmAction,
});
loadToastSettings();
if (advClose) {

View File

@@ -0,0 +1,373 @@
// 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 lastReleaseToastKey = null;
let lastLogMessage = null;
let changelogCache = { version: null, text: "" };
let lastChangelogUrl = null;
let releaseChannel = "dev";
function logRelease(msg) {
if (!msg) return;
const plain = msg.trim();
if (plain === lastLogMessage) return;
lastLogMessage = plain;
const ts = new Date().toLocaleTimeString();
const line = `${ts} ${msg}`;
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" : "";
releaseStatusMsg.classList.remove("error");
}
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 = "";
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" });
// surface via toast/log only; avoid inline red flashes
showToast("Failed to load release status", "error");
logRelease("Error: failed to load release status");
}
}
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 = "";
// Only toast once per apply/rollback cycle
if (state.status === "up_to_date" && releaseBusyActive === false) {
const key = `ok-${state.current_version || ""}-${state.latest_version || ""}`;
if (lastReleaseToastKey !== key) {
lastReleaseToastKey = key;
showToast(state.message || "Update complete", "success");
}
logRelease("Update complete");
} else if (state.status === "error") {
const key = `err-${state.message || ""}`;
if (lastReleaseToastKey !== key) {
lastReleaseToastKey = key;
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"));
// Do not allow dismiss by clicking backdrop (consistency with other modals)
releaseModal?.addEventListener("click", (e) => {
if (e.target === releaseModal) {
e.stopPropagation();
}
});
releaseCheckBtn?.addEventListener("click", async () => {
try {
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 {
lastReleaseToastKey = null;
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.");
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 {
lastReleaseToastKey = null;
releaseBusyActive = true;
showBusy("Rolling back…", "Restoring previous backup.");
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,
};
}

View File

@@ -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,69 @@ 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;
}
#releaseModal pre.log-box {
max-height: 220px !important;
min-height: 220px;
overflow-y: auto;
}
#releaseProgress {
display: none;
}
.updates-status {
display: none;
}
.updates-status.error {
display: block;
}
:root[data-theme="light"] .log-box {
background: rgba(12, 18, 32, 0.04);
}
.toast-container {
position: fixed;
bottom: 16px;
@@ -1324,6 +1402,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 +1432,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 +1450,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 +1470,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);
}

View File

@@ -115,14 +115,15 @@ export function initUpdateSettings({
function showMessage(text, isError = false) {
if (!msgEl) return;
msgEl.textContent = text || "";
msgEl.classList.toggle("error", isError);
if (text) {
if (toast && msgEl.id !== "updatesMsg") toast(text, isError ? "error" : "success");
setTimeout(() => (msgEl.textContent = ""), 2500);
// Only surface inline text for errors; successes go to toast only.
if (isError) {
msgEl.textContent = text || "Something went wrong";
msgEl.classList.add("error");
} else {
msgEl.textContent = "";
msgEl.classList.remove("error");
}
if (toast) toast(text || (isError ? "Error" : ""), isError ? "error" : "success");
}
function currentConfigFromForm() {

View File

@@ -87,6 +87,21 @@
</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 class="modal-card wide">
<div class="panel-header sticky">
@@ -102,15 +117,16 @@
</button>
</div>
<div class="controls column">
<div class="control-card">
<div class="control-card release-versions">
<div>
<p class="hint quiet">Current version</p>
<h3 id="releaseCurrent">n/a</h3>
</div>
<div>
<div class="align-right">
<p class="hint quiet">Latest available</p>
<h3 id="releaseLatest"></h3>
<p id="releaseStatusMsg" class="hint status-msg"></p>
<button id="releaseChangelogBtn" class="ghost small" title="View changelog">Changelog</button>
</div>
</div>
<div class="control-actions split-row">
@@ -118,7 +134,7 @@
Check
</button>
<button id="releaseApplyBtn" title="Download and install the latest release">
Download &amp; install
Upgrade
</button>
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
Rollback
@@ -127,8 +143,24 @@
<input type="checkbox" id="releaseAutoCheck" />
<span>Auto-check daily</span>
</label>
<label class="checkbox-row inline">
<input type="checkbox" id="releaseChannelToggle" />
<span>Allow dev builds</span>
</label>
</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>
@@ -182,6 +214,22 @@
</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 class="modal-card wide">
<div class="panel-header sticky">
@@ -678,7 +726,7 @@
</div>
</div>
<script type="module" src="assets/main.js"></script>
<script type="module" src="assets/main.js?v=20251213g"></script>
<div id="toastContainer" class="toast-container"></div>
</body>
</html>