Compare commits
6 Commits
v0.1.0-dev
...
v0.1.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4241a4da69 | ||
|
|
4e13b41bed | ||
|
|
d436d3013d | ||
|
|
b611d247b2 | ||
|
|
2bdd07b954 | ||
|
|
4461613339 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ out/
|
||||
|
||||
# Stock images (large)
|
||||
images/stock/
|
||||
|
||||
# Local helpers
|
||||
set_ready.sh
|
||||
|
||||
295
pikit-api.py
295
pikit-api.py
@@ -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))
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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=20251213f";
|
||||
|
||||
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) {
|
||||
|
||||
360
pikit-web/assets/releases.js
Normal file
360
pikit-web/assets/releases.js
Normal file
@@ -0,0 +1,360 @@
|
||||
// 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 changelogCache = { version: null, text: "" };
|
||||
let lastChangelogUrl = null;
|
||||
let releaseChannel = "dev";
|
||||
|
||||
function logRelease(msg) {
|
||||
if (!msg) return;
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
const line = `${ts} ${msg}`;
|
||||
if (releaseLogLines[0] === line) return;
|
||||
releaseLogLines.unshift(line);
|
||||
releaseLogLines = releaseLogLines.slice(0, 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"
|
||||
: status === "up_to_date"
|
||||
? msg || "Up to date"
|
||||
: msg || status;
|
||||
}
|
||||
if (releaseLogStatus) {
|
||||
releaseLogStatus.textContent =
|
||||
status === "in_progress"
|
||||
? "Running…"
|
||||
: status === "update_available"
|
||||
? "Update available"
|
||||
: status === "error"
|
||||
? "Error"
|
||||
: "Idle";
|
||||
}
|
||||
if (releaseChangelogBtn) {
|
||||
releaseChangelogBtn.disabled = status === "checking";
|
||||
releaseChangelogBtn.classList.toggle("ghost", status !== "update_available");
|
||||
}
|
||||
}
|
||||
|
||||
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" });
|
||||
if (releaseStatusMsg) {
|
||||
releaseStatusMsg.textContent = "Failed to load release status";
|
||||
releaseStatusMsg.classList.add("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = "";
|
||||
if (state.status === "up_to_date") {
|
||||
showToast(state.message || "Update complete", "success");
|
||||
logRelease("Update complete");
|
||||
} else if (state.status === "error") {
|
||||
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"));
|
||||
releaseModal?.addEventListener("click", (e) => {
|
||||
if (e.target === releaseModal) releaseModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,78 @@ 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;
|
||||
}
|
||||
.status-msg {
|
||||
display: none;
|
||||
}
|
||||
.status-msg.error {
|
||||
display: block;
|
||||
}
|
||||
#releaseStatusMsg {
|
||||
display: block;
|
||||
}
|
||||
.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 +1411,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 +1441,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 +1459,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 +1479,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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
×
|
||||
</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 & 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=20251213f"></script>
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user