Soften updater stub messaging (no error)

This commit is contained in:
Aaron
2025-12-10 19:15:30 -05:00
parent c85df728b7
commit 47bd69a092
7 changed files with 445 additions and 4 deletions

77
PLAN.md Normal file
View File

@@ -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-<version>.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/<version>/bundle.tar.gz`.
3) Verify SHA256 against manifest.
4) Unpack to staging dir.
5) Backup current managed files to `/var/backups/pikit/<ts>/` (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).

View File

@@ -2,6 +2,8 @@
import json, os, subprocess, socket, shutil, pathlib, datetime import json, os, subprocess, socket, shutil, pathlib, datetime
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
import re import re
import urllib.request
import hashlib
HOST = "127.0.0.1" HOST = "127.0.0.1"
PORT = 4000 PORT = 4000
@@ -27,6 +29,17 @@ ALL_PATTERNS = [
*SECURITY_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): class FirewallToolMissing(Exception):
"""Raised when ufw is unavailable but a firewall change was requested.""" """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) 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): 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):
@@ -551,6 +664,10 @@ class Handler(BaseHTTPRequestHandler):
elif self.path.startswith("/api/updates/config"): elif self.path.startswith("/api/updates/config"):
cfg = read_updates_config() cfg = read_updates_config()
self._send(200, cfg) 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: else:
self._send(404, {"error": "not found"}) self._send(404, {"error": "not found"})
@@ -579,6 +696,20 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e: except Exception as e:
dbg(f"Failed to apply updates config: {e}") dbg(f"Failed to apply updates config: {e}")
return self._send(500, {"error": str(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"): 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

@@ -6,9 +6,14 @@ const headers = { "Content-Type": "application/json" };
export async function api(path, opts = {}) { export async function api(path, opts = {}) {
// When running `npm run dev` without the backend, allow mock JSON from /data/ // When running `npm run dev` without the backend, allow mock JSON from /data/
const isMock = import.meta?.env?.MODE === 'development' && path.startsWith('/api'); 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') const mockMap = {
: path; '/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 }); const res = await fetch(target, { headers, ...opts });
@@ -38,6 +43,26 @@ export const saveUpdateConfig = (config) =>
body: JSON.stringify(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) => export const triggerReset = (confirm) =>
api("/api/reset", { api("/api/reset", {
method: "POST", method: "POST",

View File

@@ -1,6 +1,14 @@
// Entry point for the dashboard: wires UI events, pulls status, and initializes // Entry point for the dashboard: wires UI events, pulls status, and initializes
// feature modules (services, settings, stats). // feature modules (services, settings, stats).
import { getStatus, triggerReset } from "./api.js"; import {
getStatus,
triggerReset,
getReleaseStatus,
checkRelease,
applyRelease,
rollbackRelease,
setReleaseAutoCheck,
} from "./api.js";
import { placeholderStatus, renderStats } from "./status.js"; import { placeholderStatus, renderStats } from "./status.js";
import { initServiceControls, renderServices } from "./services.js"; import { initServiceControls, renderServices } from "./services.js";
import { initSettings } from "./settings.js"; import { initSettings } from "./settings.js";
@@ -22,6 +30,7 @@ const updatesStatus = document.getElementById("updatesStatus");
const updatesFlagTop = document.getElementById("updatesFlagTop"); const updatesFlagTop = document.getElementById("updatesFlagTop");
const updatesNoteTop = document.getElementById("updatesNoteTop"); const updatesNoteTop = document.getElementById("updatesNoteTop");
const tempFlagTop = document.getElementById("tempFlagTop"); const tempFlagTop = document.getElementById("tempFlagTop");
const releaseFlagTop = document.getElementById("releaseFlagTop");
const refreshIntervalInput = document.getElementById("refreshIntervalInput"); const refreshIntervalInput = document.getElementById("refreshIntervalInput");
const refreshIntervalSave = document.getElementById("refreshIntervalSave"); const refreshIntervalSave = document.getElementById("refreshIntervalSave");
const refreshIntervalMsg = document.getElementById("refreshIntervalMsg"); const refreshIntervalMsg = document.getElementById("refreshIntervalMsg");
@@ -74,6 +83,18 @@ const menuClose = document.getElementById("menuClose");
const advBtn = document.getElementById("advBtn"); const advBtn = document.getElementById("advBtn");
const advModal = document.getElementById("advModal"); const advModal = document.getElementById("advModal");
const advClose = document.getElementById("advClose"); const advClose = document.getElementById("advClose");
const releaseBtn = document.getElementById("releaseBtn");
const releaseModal = document.getElementById("releaseModal");
const releaseClose = document.getElementById("releaseClose");
const releaseCurrent = document.getElementById("releaseCurrent");
const releaseLatest = document.getElementById("releaseLatest");
const releaseStatusMsg = document.getElementById("releaseStatusMsg");
const releaseProgress = document.getElementById("releaseProgress");
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
const releaseRollbackBtn = document.getElementById("releaseRollbackBtn");
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
const helpBtn = document.getElementById("helpBtn"); const helpBtn = document.getElementById("helpBtn");
const helpModal = document.getElementById("helpModal"); const helpModal = document.getElementById("helpModal");
const helpClose = document.getElementById("helpClose"); const helpClose = document.getElementById("helpClose");
@@ -228,6 +249,7 @@ function applyTooltips() {
updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.", updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.",
refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.", refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.",
tempFlagTop: "CPU temperature status; see details in the hero stats below.", 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", themeToggle: "Toggle light or dark theme",
helpBtn: "Open quick help", helpBtn: "Open quick help",
advBtn: "Open settings", advBtn: "Open settings",
@@ -327,6 +349,8 @@ async function loadStatus() {
setTimeout(loadStatus, 3000); setTimeout(loadStatus, 3000);
} }
} }
// Pull Pi-Kit release status after core status
loadReleaseStatus();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
renderStats(heroStats, placeholderStatus); renderStats(heroStats, placeholderStatus);
@@ -361,6 +385,67 @@ function updatesFlagEl(enabled) {
else if (enabled === false) updatesFlagTop.classList.add("chip-off"); 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() { function wireModals() {
advBtn.onclick = () => advModal.classList.remove("hidden"); advBtn.onclick = () => advModal.classList.remove("hidden");
advClose.onclick = () => advModal.classList.add("hidden"); advClose.onclick = () => advModal.classList.add("hidden");
@@ -374,6 +459,65 @@ function wireModals() {
addServiceModal?.addEventListener("click", (e) => { addServiceModal?.addEventListener("click", (e) => {
if (e.target === addServiceModal) addServiceModal.classList.add("hidden"); if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
}); });
releaseBtn?.addEventListener("click", () => {
releaseModal?.classList.remove("hidden");
loadReleaseStatus();
});
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
releaseModal?.addEventListener("click", (e) => {
if (e.target === releaseModal) releaseModal.classList.add("hidden");
});
}
function wireReleaseControls() {
releaseCheckBtn?.addEventListener("click", async () => {
try {
if (releaseProgress) releaseProgress.textContent = "Checking for updates…";
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.") { function showBusy(title = "Working…", text = "This may take a few seconds.") {
@@ -449,6 +593,7 @@ if (typeof window !== "undefined") {
function main() { function main() {
applyTooltips(); applyTooltips();
wireModals(); wireModals();
wireReleaseControls();
wireResetAndUpdates(); wireResetAndUpdates();
wireAccordions(); wireAccordions();
loadToastSettings(); loadToastSettings();

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
{
"version": "0.1.0-dev"
}

View File

@@ -22,6 +22,7 @@
<span id="updatesNoteTop" class="hint quiet"></span> <span id="updatesNoteTop" class="hint quiet"></span>
<span id="refreshFlagTop" class="status-chip quiet">Refresh: 10s</span> <span id="refreshFlagTop" class="status-chip quiet">Refresh: 10s</span>
<span id="tempFlagTop" class="status-chip quiet">Temp: OK</span> <span id="tempFlagTop" class="status-chip quiet">Temp: OK</span>
<span id="releaseFlagTop" class="status-chip quiet">Pi-Kit: n/a</span>
</div> </div>
<div class="top-actions"> <div class="top-actions">
<button <button
@@ -32,6 +33,9 @@
> >
<span id="themeToggleIcon" aria-hidden="true">&#127769;</span> <span id="themeToggleIcon" aria-hidden="true">&#127769;</span>
</button> </button>
<button id="releaseBtn" class="ghost" title="Pi-Kit updates">
Update
</button>
<button id="aboutBtn" class="ghost" title="About Pi-Kit">About</button> <button id="aboutBtn" class="ghost" title="About Pi-Kit">About</button>
<button id="helpBtn" class="ghost" title="Open help">Help</button> <button id="helpBtn" class="ghost" title="Open help">Help</button>
<button id="advBtn" class="ghost" title="Open settings"> <button id="advBtn" class="ghost" title="Open settings">
@@ -83,6 +87,52 @@
</div> </div>
</div> </div>
<div id="releaseModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow">Updates</p>
<h3>Pi-Kit releases</h3>
<p class="hint">
Check for a new Pi-Kit release, download it, and install from here.
</p>
</div>
<button id="releaseClose" class="ghost icon-btn close-btn" title="Close updates panel">
&times;
</button>
</div>
<div class="controls column">
<div class="control-card">
<div>
<p class="hint quiet">Current version</p>
<h3 id="releaseCurrent">n/a</h3>
</div>
<div>
<p class="hint quiet">Latest available</p>
<h3 id="releaseLatest"></h3>
<p id="releaseStatusMsg" class="hint status-msg"></p>
</div>
</div>
<div class="control-actions split-row">
<button id="releaseCheckBtn" class="ghost" title="Check for a new release">
Check
</button>
<button id="releaseApplyBtn" title="Download and install the latest release">
Download &amp; install
</button>
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
Rollback
</button>
<label class="checkbox-row inline">
<input type="checkbox" id="releaseAutoCheck" />
<span>Auto-check daily</span>
</label>
</div>
<div id="releaseProgress" class="hint status-msg"></div>
</div>
</div>
</div>
<div id="busyOverlay" class="overlay hidden"> <div id="busyOverlay" class="overlay hidden">
<div class="overlay-box"> <div class="overlay-box">
<h3 id="busyTitle">Working…</h3> <h3 id="busyTitle">Working…</h3>