Soften updater stub messaging (no error)
This commit is contained in:
77
PLAN.md
Normal file
77
PLAN.md
Normal 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).
|
||||||
|
|
||||||
131
pikit-api.py
131
pikit-api.py
@@ -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))
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
10
pikit-web/data/mock-update-status.json
Normal file
10
pikit-web/data/mock-update-status.json
Normal 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
|
||||||
|
}
|
||||||
3
pikit-web/data/version.json
Normal file
3
pikit-web/data/version.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"version": "0.1.0-dev"
|
||||||
|
}
|
||||||
@@ -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">🌙</span>
|
<span id="themeToggleIcon" aria-hidden="true">🌙</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">
|
||||||
|
×
|
||||||
|
</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 & 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user