From 47bd69a0929177e35a0c57b6b82c3c75c531a820 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 10 Dec 2025 19:15:30 -0500 Subject: [PATCH] Soften updater stub messaging (no error) --- PLAN.md | 77 +++++++++++++ pikit-api.py | 131 ++++++++++++++++++++++ pikit-web/assets/api.js | 31 +++++- pikit-web/assets/main.js | 147 ++++++++++++++++++++++++- pikit-web/data/mock-update-status.json | 10 ++ pikit-web/data/version.json | 3 + pikit-web/index.html | 50 +++++++++ 7 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 PLAN.md create mode 100644 pikit-web/data/mock-update-status.json create mode 100644 pikit-web/data/version.json diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..9c751d6 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,77 @@ +# Pi-Kit Updater Plan + +## Goals +- Add seamless in-dashboard updates for Pi-Kit assets (web UI, api script, helper scripts). +- Allow manual check/apply + rollback; optional daily auto-check with a status chip. +- Use release tarballs (not git clones) from Gitea; verify hashes; stage before swap; keep one backup. +- Keep UX friendly: clear progress, errors, and “update available” indicator. + +## Release Format (Gitea) +- Release asset: `pikit-.tar.gz` containing: + - `pikit-web/` (built assets only, no node_modules) + - `pikit-api.py` + - helper scripts/configs that should overwrite + - `manifest.json` (see below) +- `manifest.json` fields: + - `version` (string) + - `changelog` (url) + - `files`: array of { `path`, `sha256`, `mode`? } + - `post_install` (optional array of commands or service restarts) +- Optional: detached signature in future (ed25519), not in v1. + +## On-device Updater (Python helper) +- Location: `/usr/local/bin/pikit-updater.py` (invoked by API and timer). +- State file: `/var/lib/pikit-update/state.json` (latest, current, last_check, last_result, progress, backup_path). +- Steps for apply: + 1) Fetch manifest (HTTP GET release URL). + 2) Download tarball to `/var/tmp/pikit-update//bundle.tar.gz`. + 3) Verify SHA256 against manifest. + 4) Unpack to staging dir. + 5) Backup current managed files to `/var/backups/pikit//` (keep 1 latest). + 6) Rsync staged files to destinations: + - `/var/www/pikit-web/` (built assets) + - `/usr/local/bin/pikit-api.py` + 7) Restart services: `dietpi-dashboard-frontend`, api service (if exists), maybe nginx reload. + 8) Update state; mark current version file `/etc/pikit/version`. +- Rollback: restore latest backup and restart services. +- Concurrency: use lockfile in `/var/run/pikit-update.lock`. + +## API additions (pikit-api.py) +- `GET /api/update/status` -> returns state.json contents + current version. +- `POST /api/update/check` -> fetch manifest only, update state. +- `POST /api/update/apply` -> spawn updater apply (can run inline for v1) and stream status. +- `POST /api/update/rollback` -> restore last backup. +- `POST /api/update/auto` -> enable/disable daily timer. + +## Timer +- systemd timer: `pikit-update.timer/service` running check daily (e.g., 04:20). +- Writes last_check and latest_version to state. + +## UI changes (pikit-web) +- Top-bar chip: “Updates: Up to date / Update available / Checking…”. +- New “Updates” modal (not the OS auto-updates) for Pi-Kit releases: + - Show current vs latest version, changelog link. + - Buttons: Check, Download & Install, Rollback, View logs. + - Progress states + errors. +- Hook into existing status refresh: call `/api/update/status`. + +## Safety / Edge +- Preflight disk space check before download. +- Handle offline: clear message. +- If apply fails, auto rollback. +- Skip overwriting user-edited config? For v1 assume assets/api are managed; nginx site left unchanged. + +## Implementation steps +1) Add current version markers (maybe file in repo, read by UI). +2) Implement updater helper script with check/apply/rollback + state. +3) Add systemd service/timer units template (install script TBD, for now keep files in repo). +4) Extend `pikit-api.py` with new endpoints calling updater functions. +5) Extend UI (status chip + modal) and wire to API. +6) Add mock responses for dev (mock-update-status.json). +7) Test locally (mock) and on device (manual apply pointing to a dummy URL or skip download logic for now). + +## Open questions / assumptions +- Release hosting: use Gitea releases at `https://git.44r0n.cc/44r0n7/pi-kit/releases/latest` (JSON API? or static URLs). For v1, hardcode base URL env var or config. +- Services to restart: assume `dietpi-dashboard-frontend` and `pikit-api` (if we add a service file). Need to verify actual service names on device. +- Device perms: updater runs as root (via sudo from API). Ensure API endpoint requires auth (existing login). + diff --git a/pikit-api.py b/pikit-api.py index 68cec02..3299495 100644 --- a/pikit-api.py +++ b/pikit-api.py @@ -2,6 +2,8 @@ import json, os, subprocess, socket, shutil, pathlib, datetime from http.server import BaseHTTPRequestHandler, HTTPServer import re +import urllib.request +import hashlib HOST = "127.0.0.1" PORT = 4000 @@ -27,6 +29,17 @@ ALL_PATTERNS = [ *SECURITY_PATTERNS, ] +# Release updater constants +VERSION_FILE = pathlib.Path("/etc/pikit/version") +WEB_VERSION_FILE = pathlib.Path("/var/www/pikit-web/data/version.json") +UPDATE_STATE_DIR = pathlib.Path("/var/lib/pikit-update") +UPDATE_STATE = UPDATE_STATE_DIR / "state.json" +UPDATE_LOCK = pathlib.Path("/var/run/pikit-update.lock") +DEFAULT_MANIFEST_URL = os.environ.get( + "PIKIT_MANIFEST_URL", + "https://git.44r0n.cc/44r0n7/pi-kit/releases/latest/download/manifest.json", +) + class FirewallToolMissing(Exception): """Raised when ufw is unavailable but a firewall change was requested.""" @@ -459,6 +472,106 @@ def reset_firewall(): subprocess.run(["ufw", "--force", "enable"], check=False) +def read_current_version(): + if VERSION_FILE.exists(): + return VERSION_FILE.read_text().strip() + if WEB_VERSION_FILE.exists(): + try: + return json.loads(WEB_VERSION_FILE.read_text()).get("version", "unknown") + except Exception: + return "unknown" + return "unknown" + + +def load_update_state(): + UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True) + if UPDATE_STATE.exists(): + try: + return json.loads(UPDATE_STATE.read_text()) + except Exception: + pass + return { + "current_version": read_current_version(), + "latest_version": None, + "last_check": None, + "status": "unknown", + "message": "", + "auto_check": False, + "in_progress": False, + "progress": None, + } + + +def save_update_state(state: dict): + UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True) + UPDATE_STATE.write_text(json.dumps(state, indent=2)) + + +def fetch_manifest(url: str = None): + target = url or DEFAULT_MANIFEST_URL + resp = urllib.request.urlopen(target, timeout=10) + data = resp.read() + manifest = json.loads(data.decode()) + return manifest + + +def check_for_update(): + state = load_update_state() + state["in_progress"] = True + state["progress"] = "Checking for updates…" + save_update_state(state) + try: + manifest = fetch_manifest() + 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: + state["status"] = "up_to_date" + state["message"] = "Up to date" + except Exception as e: + state["status"] = "error" + state["message"] = f"Check failed: {e}" + finally: + state["in_progress"] = False + state["progress"] = None + save_update_state(state) + return state + + +def apply_update_stub(): + """Placeholder apply that marks not implemented but keeps state coherent.""" + state = load_update_state() + state["in_progress"] = True + state["progress"] = "Applying update…" + save_update_state(state) + try: + # TODO: implement download + install + # For now, report that nothing was changed to avoid alarming errors. + state["status"] = "up_to_date" + state["message"] = "Updater not implemented yet; no changes were made." + # Reset latest_version so the chip returns to neutral + state["latest_version"] = state.get("current_version") + except Exception as e: + state["status"] = "error" + state["message"] = f"Apply failed: {e}" + finally: + state["in_progress"] = False + state["progress"] = None + save_update_state(state) + return state + + +def rollback_update_stub(): + state = load_update_state() + state["status"] = "up_to_date" + state["message"] = "Rollback not implemented yet; no changes were made." + save_update_state(state) + return state + + class Handler(BaseHTTPRequestHandler): """Minimal JSON API for the dashboard (status, services, updates, reset).""" def _send(self, code, data): @@ -551,6 +664,10 @@ class Handler(BaseHTTPRequestHandler): elif self.path.startswith("/api/updates/config"): cfg = read_updates_config() self._send(200, cfg) + elif self.path.startswith("/api/update/status"): + state = load_update_state() + state["current_version"] = read_current_version() + self._send(200, state) else: self._send(404, {"error": "not found"}) @@ -579,6 +696,20 @@ class Handler(BaseHTTPRequestHandler): except Exception as e: dbg(f"Failed to apply updates config: {e}") return self._send(500, {"error": str(e)}) + if self.path.startswith("/api/update/check"): + state = check_for_update() + return self._send(200, state) + if self.path.startswith("/api/update/apply"): + state = apply_update_stub() + return self._send(200, state) + if self.path.startswith("/api/update/rollback"): + state = rollback_update_stub() + return self._send(200, state) + if self.path.startswith("/api/update/auto"): + state = load_update_state() + state["auto_check"] = bool(payload.get("enable")) + 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 5aa27cc..41cbafa 100644 --- a/pikit-web/assets/api.js +++ b/pikit-web/assets/api.js @@ -6,9 +6,14 @@ const headers = { "Content-Type": "application/json" }; export async function api(path, opts = {}) { // When running `npm run dev` without the backend, allow mock JSON from /data/ const isMock = import.meta?.env?.MODE === 'development' && path.startsWith('/api'); - const target = isMock - ? path.replace('/api/status', '/data/mock-status.json').replace('/api/updates/config', '/data/mock-updates.json') - : path; + + const mockMap = { + '/api/status': '/data/mock-status.json', + '/api/updates/config': '/data/mock-updates.json', + '/api/update/status': '/data/mock-update-status.json', + }; + + const target = isMock && mockMap[path] ? mockMap[path] : path; const res = await fetch(target, { headers, ...opts }); @@ -38,6 +43,26 @@ export const saveUpdateConfig = (config) => body: JSON.stringify(config), }); +// Pi-Kit release updater endpoints +export const getReleaseStatus = () => api("/api/update/status"); +export const checkRelease = () => + api("/api/update/check", { + method: "POST", + }); +export const applyRelease = () => + api("/api/update/apply", { + method: "POST", + }); +export const rollbackRelease = () => + api("/api/update/rollback", { + method: "POST", + }); +export const setReleaseAutoCheck = (enable) => + api("/api/update/auto", { + method: "POST", + body: JSON.stringify({ enable }), + }); + export const triggerReset = (confirm) => api("/api/reset", { method: "POST", diff --git a/pikit-web/assets/main.js b/pikit-web/assets/main.js index 467ce54..e7c55e7 100644 --- a/pikit-web/assets/main.js +++ b/pikit-web/assets/main.js @@ -1,6 +1,14 @@ // Entry point for the dashboard: wires UI events, pulls status, and initializes // feature modules (services, settings, stats). -import { getStatus, triggerReset } from "./api.js"; +import { + getStatus, + triggerReset, + getReleaseStatus, + checkRelease, + applyRelease, + rollbackRelease, + setReleaseAutoCheck, +} from "./api.js"; import { placeholderStatus, renderStats } from "./status.js"; import { initServiceControls, renderServices } from "./services.js"; import { initSettings } from "./settings.js"; @@ -22,6 +30,7 @@ const updatesStatus = document.getElementById("updatesStatus"); const updatesFlagTop = document.getElementById("updatesFlagTop"); const updatesNoteTop = document.getElementById("updatesNoteTop"); const tempFlagTop = document.getElementById("tempFlagTop"); +const releaseFlagTop = document.getElementById("releaseFlagTop"); const refreshIntervalInput = document.getElementById("refreshIntervalInput"); const refreshIntervalSave = document.getElementById("refreshIntervalSave"); const refreshIntervalMsg = document.getElementById("refreshIntervalMsg"); @@ -74,6 +83,18 @@ 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"); const helpClose = document.getElementById("helpClose"); @@ -228,6 +249,7 @@ function applyTooltips() { updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.", refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.", tempFlagTop: "CPU temperature status; see details in the hero stats below.", + releaseFlagTop: "Pi-Kit release status; open Settings → Updates to install.", themeToggle: "Toggle light or dark theme", helpBtn: "Open quick help", advBtn: "Open settings", @@ -327,6 +349,8 @@ async function loadStatus() { setTimeout(loadStatus, 3000); } } + // Pull Pi-Kit release status after core status + loadReleaseStatus(); } catch (e) { console.error(e); renderStats(heroStats, placeholderStatus); @@ -361,6 +385,67 @@ function updatesFlagEl(enabled) { 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 || {}; + 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"; +} + function wireModals() { advBtn.onclick = () => advModal.classList.remove("hidden"); advClose.onclick = () => advModal.classList.add("hidden"); @@ -374,6 +459,65 @@ 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 { + if (releaseProgress) releaseProgress.textContent = "Downloading and installing…"; + await applyRelease(); + await loadReleaseStatus(); + showToast("Update started", "success"); + } catch (e) { + showToast(e.error || "Update failed", "error"); + } finally { + if (releaseProgress) releaseProgress.textContent = ""; + } + }); + + releaseRollbackBtn?.addEventListener("click", async () => { + try { + if (releaseProgress) releaseProgress.textContent = "Rolling back…"; + await rollbackRelease(); + await loadReleaseStatus(); + showToast("Rollback complete", "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 showBusy(title = "Working…", text = "This may take a few seconds.") { @@ -449,6 +593,7 @@ if (typeof window !== "undefined") { function main() { applyTooltips(); wireModals(); + wireReleaseControls(); wireResetAndUpdates(); wireAccordions(); loadToastSettings(); diff --git a/pikit-web/data/mock-update-status.json b/pikit-web/data/mock-update-status.json new file mode 100644 index 0000000..1648d65 --- /dev/null +++ b/pikit-web/data/mock-update-status.json @@ -0,0 +1,10 @@ +{ + "current_version": "0.1.0-dev", + "latest_version": "0.1.1-mock", + "last_check": "2025-12-10T22:00:00Z", + "status": "update_available", + "message": "New UI polish and bug fixes.", + "auto_check": true, + "in_progress": false, + "progress": null +} diff --git a/pikit-web/data/version.json b/pikit-web/data/version.json new file mode 100644 index 0000000..87eed28 --- /dev/null +++ b/pikit-web/data/version.json @@ -0,0 +1,3 @@ +{ + "version": "0.1.0-dev" +} diff --git a/pikit-web/index.html b/pikit-web/index.html index 76cddee..dee9950 100644 --- a/pikit-web/index.html +++ b/pikit-web/index.html @@ -22,6 +22,7 @@ Refresh: 10s Temp: OK + Pi-Kit: n/a
+
+ +