16 Commits

Author SHA1 Message Date
Aaron
c182eb179d Show diagnostics log modal via header button; toggle visibility when enabled 2025-12-13 11:46:01 -05:00
Aaron
650175913e Move diagnostics log to modal; add header Log button 2025-12-13 11:41:13 -05:00
Aaron
5ee183d607 Harden diagnostics UI (busy states, retry refresh) 2025-12-13 11:33:04 -05:00
Aaron
48be7a1c61 Add diagnostics logging (RAM), UI viewer, and toggles 2025-12-13 11:16:57 -05:00
Aaron
28acb94a6f Limit inline settings messages to errors; restore status text defaults 2025-12-13 10:45:29 -05:00
Aaron
2c60ba981b Prevent release modal dismiss on backdrop click 2025-12-13 10:41:14 -05:00
Aaron
92e4ce88df Suppress inline error flash on release status fetch 2025-12-13 10:37:30 -05:00
Aaron
c1eb7d0765 Dedup updater log entries 2025-12-13 10:34:40 -05:00
Aaron
c66f7d78a0 Hide release status line for non-update states 2025-12-13 10:30:44 -05:00
Aaron
c20ea57da6 Throttle updater toasts and bump cache-bust 2025-12-13 10:27:35 -05:00
Aaron
4241a4da69 Hide update-settings inline success text 2025-12-13 09:52:14 -05:00
Aaron
4e13b41bed Hide inline status messages unless error; keep release status visible 2025-12-13 09:36:59 -05:00
Aaron
d436d3013d Hide inline release progress text; keep log height fixed 2025-12-13 09:33:39 -05:00
Aaron
b611d247b2 Fix updater log panel to fixed height 2025-12-13 09:31:18 -05:00
Aaron
2bdd07b954 Refactor releases UI into module; cap update log height 2025-12-13 09:27:38 -05:00
Aaron
4461613339 Updater: channel-aware apply, UI polish, cache-bust 2025-12-12 21:06:40 -05:00
10 changed files with 1332 additions and 245 deletions

3
.gitignore vendored
View File

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

View File

@@ -4,8 +4,12 @@ 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
import io
from collections import deque
HOST = "127.0.0.1"
PORT = 4000
@@ -47,6 +51,99 @@ API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py")
BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
TMP_ROOT = pathlib.Path("/var/tmp/pikit-update")
# Diagnostics logging (RAM-only)
DIAG_STATE_FILE = pathlib.Path("/dev/shm/pikit-diag.state") if pathlib.Path("/dev/shm").exists() else pathlib.Path("/tmp/pikit-diag.state")
DIAG_LOG_FILE = pathlib.Path("/dev/shm/pikit-diag.log") if pathlib.Path("/dev/shm").exists() else pathlib.Path("/tmp/pikit-diag.log")
DIAG_MAX_BYTES = 1_048_576 # 1 MiB cap in RAM
DIAG_MAX_ENTRY_CHARS = 2048
DIAG_DEFAULT_STATE = {"enabled": False, "level": "normal"} # level: normal|debug
_diag_state = None
def _load_diag_state():
global _diag_state
if _diag_state is not None:
return _diag_state
try:
if DIAG_STATE_FILE.exists():
_diag_state = json.loads(DIAG_STATE_FILE.read_text())
return _diag_state
except Exception:
pass
_diag_state = DIAG_DEFAULT_STATE.copy()
return _diag_state
def _save_diag_state(enabled=None, level=None):
state = _load_diag_state()
if enabled is not None:
state["enabled"] = bool(enabled)
if level in ("normal", "debug"):
state["level"] = level
try:
DIAG_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
DIAG_STATE_FILE.write_text(json.dumps(state))
except Exception:
pass
return state
def diag_log(level: str, message: str, meta: dict | None = None):
"""
Append a diagnostic log line to RAM-backed file.
Skips when disabled or when debug level is off.
"""
state = _load_diag_state()
if not state.get("enabled"):
return
if level == "debug" and state.get("level") != "debug":
return
try:
ts = datetime.datetime.utcnow().isoformat() + "Z"
entry = {"ts": ts, "level": level, "msg": message}
if meta:
entry["meta"] = meta
line = json.dumps(entry, separators=(",", ":"))
if len(line) > DIAG_MAX_ENTRY_CHARS:
entry.pop("meta", None)
entry["msg"] = (message or "")[: DIAG_MAX_ENTRY_CHARS - 40] + ""
line = json.dumps(entry, separators=(",", ":"))
line_bytes = (line + "\n").encode()
DIAG_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with DIAG_LOG_FILE.open("ab") as f:
f.write(line_bytes)
# Trim file if above cap
if DIAG_LOG_FILE.stat().st_size > DIAG_MAX_BYTES:
with DIAG_LOG_FILE.open("rb") as f:
f.seek(-DIAG_MAX_BYTES, io.SEEK_END)
tail = f.read()
# drop partial first line to keep JSON lines clean
if b"\n" in tail:
tail = tail.split(b"\n", 1)[1]
with DIAG_LOG_FILE.open("wb") as f:
f.write(tail)
except Exception:
# Never break caller
pass
def diag_read(limit=500):
"""Return latest log entries (dicts), newest first."""
if not DIAG_LOG_FILE.exists():
return []
try:
data = DIAG_LOG_FILE.read_bytes()
except Exception:
return []
lines = data.splitlines()[-limit:]
out = []
for line in lines:
try:
out.append(json.loads(line.decode("utf-8", errors="ignore")))
except Exception:
continue
return out[::-1]
def ensure_dir(path: pathlib.Path):
path.mkdir(parents=True, exist_ok=True)
@@ -78,12 +175,17 @@ def normalize_path(path: str | None) -> str:
def dbg(msg):
if not DEBUG_FLAG:
return
# Legacy debug file logging (when /boot/pikit-debug exists)
if DEBUG_FLAG:
API_LOG.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.datetime.utcnow().isoformat()
with API_LOG.open("a") as f:
f.write(f"[{ts}] {msg}\n")
# Mirror into diagnostics if enabled
try:
diag_log("debug", msg)
except Exception:
pass
def set_ssh_password_auth(allow: bool):
@@ -518,6 +620,7 @@ def load_update_state():
"auto_check": False,
"in_progress": False,
"progress": None,
"channel": os.environ.get("PIKIT_CHANNEL", "dev"),
}
@@ -526,27 +629,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()
@@ -555,24 +777,32 @@ def check_for_update():
state["message"] = "Another update is running"
save_update_state(state)
return state
diag_log("info", "Update check started", {"channel": state.get("channel") or "dev"})
state["in_progress"] = True
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")
else:
state["status"] = "up_to_date"
state["message"] = "Up to date"
diag_log("info", "Update check finished", {"status": state["status"], "latest": str(latest)})
except Exception as e:
state["status"] = "up_to_date"
state["message"] = f"Could not reach update server: {e}"
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
diag_log("error", "Update check failed", {"error": str(e)})
finally:
state["in_progress"] = False
state["progress"] = None
@@ -602,13 +832,30 @@ def apply_update_stub():
state["status"] = "in_progress"
state["progress"] = "Starting update…"
save_update_state(state)
diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"})
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:
@@ -620,6 +867,7 @@ def apply_update_stub():
state["progress"] = "Downloading release…"
save_update_state(state)
download_file(bundle_url, bundle_path)
diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)})
# Verify hash if provided
expected_hash = None
@@ -631,6 +879,7 @@ def apply_update_stub():
got = sha256_file(bundle_path)
if got.lower() != expected_hash.lower():
raise RuntimeError("Bundle hash mismatch")
diag_log("debug", "Bundle hash verified", {"expected": expected_hash})
state["progress"] = "Staging files…"
save_update_state(state)
@@ -638,19 +887,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():
@@ -674,32 +910,30 @@ def apply_update_stub():
state["message"] = "Update installed"
state["progress"] = None
save_update_state(state)
diag_log("info", "Update applied", {"version": str(latest)})
except urllib.error.HTTPError as e:
state["status"] = "error"
state["message"] = f"No release available ({e.code})"
diag_log("error", "Update apply HTTP error", {"code": e.code})
except Exception as e:
state["status"] = "error"
state["message"] = f"Update failed: {e}"
state["progress"] = None
save_update_state(state)
diag_log("error", "Update apply failed", {"error": str(e)})
# 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)
diag_log("info", "Rollback after failed update", {"backup": backup.name})
except Exception as re:
state["message"] += f" (rollback failed: {re})"
save_update_state(state)
diag_log("error", "Rollback after failed update failed", {"error": str(re)})
finally:
state["in_progress"] = False
state["progress"] = None
@@ -721,8 +955,9 @@ 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:
diag_log("info", "Rollback started")
backup = choose_rollback_backup()
if not backup:
state["status"] = "error"
state["message"] = "No backup available to rollback."
state["in_progress"] = False
@@ -730,21 +965,19 @@ 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}"
diag_log("info", "Rollback complete", {"backup": backup.name, "version": ver})
except Exception as e:
state["status"] = "error"
state["message"] = f"Rollback failed: {e}"
diag_log("error", "Rollback failed", {"error": str(e)})
state["in_progress"] = False
state["progress"] = None
save_update_state(state)
@@ -795,12 +1028,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 +1187,27 @@ 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)})
elif self.path.startswith("/api/diag/log"):
entries = diag_read()
state = _load_diag_state()
return self._send(200, {"entries": entries, "state": state})
else:
self._send(404, {"error": "not found"})
@@ -907,6 +1218,7 @@ class Handler(BaseHTTPRequestHandler):
if payload.get("confirm") == "YES":
self._send(200, {"message": "Resetting and rebooting..."})
dbg("Factory reset triggered via API")
diag_log("info", "Factory reset requested")
factory_reset()
else:
self._send(400, {"error": "type YES to confirm"})
@@ -916,14 +1228,17 @@ class Handler(BaseHTTPRequestHandler):
set_auto_updates(enable)
dbg(f"Auto updates set to {enable}")
state = auto_updates_state()
diag_log("info", "Auto updates toggled", {"enabled": enable})
return self._send(200, {"enabled": state.get("enabled", False), "details": state})
if self.path.startswith("/api/updates/config"):
try:
cfg = set_updates_config(payload or {})
dbg(f"Update settings applied: {cfg}")
diag_log("info", "Update settings saved", cfg)
return self._send(200, cfg)
except Exception as e:
dbg(f"Failed to apply updates config: {e}")
diag_log("error", "Update settings save failed", {"error": str(e)})
return self._send(500, {"error": str(e)})
if self.path.startswith("/api/update/check"):
state = check_for_update()
@@ -947,7 +1262,28 @@ class Handler(BaseHTTPRequestHandler):
state = load_update_state()
state["auto_check"] = bool(payload.get("enable"))
save_update_state(state)
diag_log("info", "Release auto-check toggled", {"enabled": state["auto_check"]})
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)
diag_log("info", "Release channel set", {"channel": chan})
return self._send(200, state)
if self.path.startswith("/api/diag/log/level"):
state = _save_diag_state(payload.get("enabled"), payload.get("level"))
diag_log("info", "Diag level updated", state)
return self._send(200, {"state": state})
if self.path.startswith("/api/diag/log/clear"):
try:
DIAG_LOG_FILE.unlink(missing_ok=True)
except Exception:
pass
diag_log("info", "Diag log cleared")
return self._send(200, {"cleared": True, "state": _load_diag_state()})
if self.path.startswith("/api/services/add"):
name = payload.get("name")
port = int(payload.get("port", 0))
@@ -981,6 +1317,7 @@ class Handler(BaseHTTPRequestHandler):
allow_port_lan(port)
except FirewallToolMissing as e:
return self._send(500, {"error": str(e)})
diag_log("info", "Service added", {"name": name, "port": port, "scheme": scheme})
return self._send(200, {"services": services, "message": f"Added {name} on port {port} and opened LAN firewall"})
if self.path.startswith("/api/services/remove"):
port = int(payload.get("port", 0))
@@ -994,6 +1331,7 @@ class Handler(BaseHTTPRequestHandler):
except FirewallToolMissing as e:
return self._send(500, {"error": str(e)})
save_services(services)
diag_log("info", "Service removed", {"port": port})
return self._send(200, {"services": services, "message": f"Removed service on port {port}"})
if self.path.startswith("/api/services/update"):
port = int(payload.get("port", 0))
@@ -1071,6 +1409,7 @@ class Handler(BaseHTTPRequestHandler):
if not updated:
return self._send(404, {"error": "service not found"})
save_services(services)
diag_log("info", "Service updated", {"port": target_port, "name": new_name or None, "scheme": scheme})
return self._send(200, {"services": services, "message": "Service updated"})
self._send(404, {"error": "not found"})

View File

@@ -62,6 +62,11 @@ export const setReleaseAutoCheck = (enable) =>
method: "POST",
body: JSON.stringify({ enable }),
});
export const setReleaseChannel = (channel) =>
api("/api/update/channel", {
method: "POST",
body: JSON.stringify({ channel }),
});
export const triggerReset = (confirm) =>
api("/api/reset", {
@@ -112,3 +117,15 @@ export const removeService = ({ port }) =>
method: "POST",
body: JSON.stringify({ port }),
});
// Diagnostics
export const getDiagLog = () => api("/api/diag/log");
export const setDiagLevel = ({ enabled, level }) =>
api("/api/diag/log/level", {
method: "POST",
body: JSON.stringify({ enabled, level }),
});
export const clearDiagLog = () =>
api("/api/diag/log/clear", {
method: "POST",
});

View File

@@ -0,0 +1,4 @@
.diag-log-modal .log-box {
max-height: 60vh;
min-height: 300px;
}

218
pikit-web/assets/diaglog.js Normal file
View File

@@ -0,0 +1,218 @@
// Diagnostic logging (frontend side)
// Maintains a client-side ring buffer, fetches server logs, and wires UI controls.
import { getDiagLog, setDiagLevel, clearDiagLog } from "./api.js";
const UI_MAX = 500;
const uiBuffer = [];
let uiEnabled = false;
let uiLevel = "normal";
let clickListenerAttached = false;
let loading = false;
function appendUi(level, msg, meta = null) {
if (!uiEnabled) return;
if (level === "debug" && uiLevel !== "debug") return;
const ts = new Date().toISOString();
const entry = { ts, level, msg, meta, source: "ui" };
uiBuffer.unshift(entry);
if (uiBuffer.length > UI_MAX) uiBuffer.length = UI_MAX;
}
function attachClickTracker() {
if (clickListenerAttached) return;
clickListenerAttached = true;
document.addEventListener(
"click",
(e) => {
if (!uiEnabled || uiLevel !== "debug") return;
const el = e.target.closest("button,input,select,textarea,label");
if (!el) return;
const label =
el.getAttribute("aria-label") ||
el.getAttribute("title") ||
el.textContent?.trim()?.slice(0, 60) ||
el.id ||
el.tagName.toLowerCase();
appendUi("debug", `UI click: ${label || el.tagName}`, {
id: el.id || null,
type: el.tagName.toLowerCase(),
});
},
true,
);
}
export function logUi(msg, level = "info", meta) {
appendUi(level, msg, meta);
}
export async function initDiagUI({ elements, toast }) {
const {
enableToggle,
debugToggle,
refreshBtn,
clearBtn,
copyBtn,
downloadBtn,
logBox,
statusEl,
logButton,
modal,
modalClose,
} = elements;
const setBusy = (on) => {
loading = on;
[refreshBtn, clearBtn, copyBtn, downloadBtn, enableToggle, debugToggle].forEach((el) => {
if (el) el.disabled = !!on;
});
};
async function syncState() {
const data = await getDiagLog();
const state = data.state || {};
uiEnabled = !!state.enabled;
uiLevel = state.level || "normal";
if (enableToggle) enableToggle.checked = uiEnabled;
if (debugToggle) debugToggle.checked = uiLevel === "debug";
if (logButton) logButton.classList.toggle("hidden", !uiEnabled);
if (modal && !uiEnabled) modal.classList.add("hidden");
return data.entries || [];
}
function render(entries) {
if (!logBox) return;
const merged = [
...(entries || []).map((e) => ({ ...e, source: "api" })),
...uiBuffer,
].sort((a, b) => (a.ts < b.ts ? 1 : -1));
logBox.textContent = merged
.map((e) => `${new Date(e.ts).toLocaleTimeString()} [${e.source || "api"} ${e.level}] ${e.msg}`)
.join("\n");
if (statusEl) statusEl.textContent = `${merged.length} entries`;
}
async function refresh() {
if (loading) return;
setBusy(true);
try {
const entries = await syncState();
render(entries);
toast?.("Diagnostics refreshed", "success");
} catch (e) {
toast?.(e.error || "Failed to load diagnostics", "error");
// retry once if failed
try {
const entries = await syncState();
render(entries);
toast?.("Diagnostics refreshed (after retry)", "success");
} catch {
// swallow
}
} finally {
setBusy(false);
}
}
enableToggle?.addEventListener("change", async () => {
try {
setBusy(true);
uiEnabled = enableToggle.checked;
await setDiagLevel({ enabled: uiEnabled, level: uiLevel });
appendUi("info", `Diagnostics ${uiEnabled ? "enabled" : "disabled"}`);
if (uiEnabled) attachClickTracker();
await refresh();
} catch (e) {
toast?.(e.error || "Failed to save diagnostics setting", "error");
enableToggle.checked = !enableToggle.checked;
} finally {
setBusy(false);
}
});
debugToggle?.addEventListener("change", async () => {
try {
setBusy(true);
uiLevel = debugToggle.checked ? "debug" : "normal";
await setDiagLevel({ enabled: uiEnabled, level: uiLevel });
appendUi("info", `Diagnostics level set to ${uiLevel}`);
if (uiEnabled) attachClickTracker();
await refresh();
} catch (e) {
toast?.(e.error || "Failed to save level", "error");
debugToggle.checked = uiLevel === "debug";
} finally {
setBusy(false);
}
});
refreshBtn?.addEventListener("click", refresh);
clearBtn?.addEventListener("click", async () => {
try {
setBusy(true);
await clearDiagLog();
uiBuffer.length = 0;
appendUi("info", "Cleared diagnostics");
await refresh();
} catch (e) {
toast?.(e.error || "Failed to clear log", "error");
} finally {
setBusy(false);
}
});
copyBtn?.addEventListener("click", async () => {
try {
const text = logBox?.textContent || "";
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text || "No log entries.");
} else {
const ta = document.createElement("textarea");
ta.value = text || "No log entries.";
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
toast?.("Diagnostics copied", "success");
} catch (e) {
toast?.("Copy failed", "error");
}
});
downloadBtn?.addEventListener("click", () => {
try {
const blob = new Blob([logBox?.textContent || "No log entries."], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "pikit-diagnostics.txt";
a.click();
URL.revokeObjectURL(url);
} catch (e) {
toast?.("Download failed", "error");
}
});
// initial load
attachClickTracker();
await refresh();
logButton?.addEventListener("click", () => {
if (!uiEnabled) return;
modal?.classList.remove("hidden");
});
modalClose?.addEventListener("click", () => modal?.classList.add("hidden"));
modal?.addEventListener("click", (e) => {
if (e.target === modal) e.stopPropagation(); // prevent accidental close
});
return {
logUi,
refresh,
};
}

View File

@@ -1,18 +1,12 @@
// 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=20251213h";
import { initDiagUI, logUi } from "./diaglog.js?v=20251213i";
const servicesGrid = document.getElementById("servicesGrid");
const heroStats = document.getElementById("heroStats");
@@ -83,17 +77,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 +89,27 @@ 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 diagEnableToggle = document.getElementById("diagEnableToggle");
const diagDebugToggle = document.getElementById("diagDebugToggle");
const diagRefreshBtn = document.getElementById("diagRefreshBtn");
const diagClearBtn = document.getElementById("diagClearBtn");
const diagCopyBtn = document.getElementById("diagCopyBtn");
const diagDownloadBtn = document.getElementById("diagDownloadBtn");
const diagLogBox = document.getElementById("diagLogBox");
const diagStatus = document.getElementById("diagStatus");
const diagLogBtn = document.getElementById("diagLogBtn");
const diagModal = document.getElementById("diagModal");
const diagClose = document.getElementById("diagClose");
const diagStatusModal = document.getElementById("diagStatusModal");
const TOAST_POS_KEY = "pikit-toast-pos";
const TOAST_ANIM_KEY = "pikit-toast-anim";
@@ -128,6 +132,7 @@ let toastAnimation = "slide-in";
let toastDurationMs = 5000;
let toastSpeedMs = 300;
let fontChoice = "redhat";
let releaseUI = null;
function applyToastSettings() {
if (!toastContainer) return;
@@ -350,7 +355,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 +384,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 +405,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 +419,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 +501,15 @@ if (typeof window !== "undefined") {
function main() {
applyTooltips();
wireModals();
wireReleaseControls();
wireResetAndUpdates();
wireAccordions();
releaseUI = initReleaseUI({
showToast,
showBusy,
hideBusy,
confirmAction,
logUi,
});
loadToastSettings();
if (advClose) {
@@ -682,6 +572,26 @@ function main() {
},
});
// Diagnostics
initDiagUI({
elements: {
enableToggle: diagEnableToggle,
debugToggle: diagDebugToggle,
refreshBtn: diagRefreshBtn,
clearBtn: diagClearBtn,
copyBtn: diagCopyBtn,
downloadBtn: diagDownloadBtn,
logBox: diagLogBox,
statusEl: diagStatusModal || diagStatus,
logButton: diagLogBtn,
modal: diagModal,
modalClose: diagClose,
},
toast: showToast,
}).catch((e) => {
console.error("Diag init failed", e);
});
// Toast controls
toastPosSelect?.addEventListener("change", () => {
const val = toastPosSelect.value;

View File

@@ -0,0 +1,376 @@
// 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, logUi = () => {} }) {
const releaseFlagTop = document.getElementById("releaseFlagTop");
const releaseBtn = document.getElementById("releaseBtn");
const releaseModal = document.getElementById("releaseModal");
const releaseClose = document.getElementById("releaseClose");
const releaseCurrent = document.getElementById("releaseCurrent");
const releaseLatest = document.getElementById("releaseLatest");
const releaseStatusMsg = document.getElementById("releaseStatusMsg");
const releaseProgress = document.getElementById("releaseProgress");
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
const releaseRollbackBtn = document.getElementById("releaseRollbackBtn");
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
const releaseLog = document.getElementById("releaseLog");
const releaseLogStatus = document.getElementById("releaseLogStatus");
const releaseLogCopy = document.getElementById("releaseLogCopy");
const releaseChangelogBtn = window.__pikitReleaseChangelogBtn || document.getElementById("releaseChangelogBtn");
const releaseChannelToggle = window.__pikitReleaseChannelToggle || document.getElementById("releaseChannelToggle");
window.__pikitReleaseChangelogBtn = releaseChangelogBtn;
window.__pikitReleaseChannelToggle = releaseChannelToggle;
const changelogModal = document.getElementById("changelogModal");
const changelogTitle = document.getElementById("changelogTitle");
const changelogBody = document.getElementById("changelogBody");
const changelogClose = document.getElementById("changelogClose");
let releaseBusyActive = false;
let releaseLogLines = [];
let releaseLastFetched = 0;
let lastReleaseLogKey = "";
let lastReleaseToastKey = null;
let lastLogMessage = null;
let changelogCache = { version: null, text: "" };
let lastChangelogUrl = null;
let releaseChannel = "dev";
function logRelease(msg) {
if (!msg) return;
const plain = msg.trim();
if (plain === lastLogMessage) return;
lastLogMessage = plain;
const ts = new Date().toLocaleTimeString();
const line = `${ts} ${msg}`;
releaseLogLines.unshift(line);
releaseLogLines = releaseLogLines.slice(0, 120);
if (releaseLog) {
releaseLog.textContent = releaseLogLines.join("\n");
releaseLog.scrollTop = 0; // keep most recent in view
}
}
function setReleaseChip(state) {
if (!releaseFlagTop) return;
releaseFlagTop.textContent = "Pi-Kit: n/a";
releaseFlagTop.className = "status-chip quiet";
if (!state) return;
const { status, latest_version, current_version, message } = state;
const label =
status === "update_available"
? `Update → ${latest_version || "new"}`
: status === "up_to_date"
? `Pi-Kit: ${current_version || "latest"}`
: status === "checking"
? "Checking…"
: status === "error"
? "Update error"
: `Pi-Kit: ${current_version || "n/a"}`;
releaseFlagTop.textContent = label;
if (status === "update_available") releaseFlagTop.classList.add("chip-warm");
if (status === "error") releaseFlagTop.classList.add("chip-off");
const msg = shorten(message, 80) || "";
releaseFlagTop.title = msg || "Pi-Kit release status";
if (releaseStatusMsg) {
releaseStatusMsg.textContent = status === "update_available" ? msg || "Update available" : "";
releaseStatusMsg.classList.remove("error");
}
if (releaseLogStatus) {
releaseLogStatus.textContent =
status === "in_progress"
? "Running…"
: status === "update_available"
? "Update available"
: status === "error"
? "Error"
: "Idle";
}
if (releaseChangelogBtn) {
releaseChangelogBtn.disabled = status === "checking";
releaseChangelogBtn.classList.toggle("ghost", status !== "update_available");
}
}
async function showChangelog(version, url) {
if (!changelogBody || !changelogModal) {
window.open(url, "_blank");
return;
}
try {
if (changelogCache.version === version && changelogCache.text) {
changelogBody.textContent = changelogCache.text;
} else {
changelogBody.textContent = "Loading changelog…";
const res = await fetch(`/api/update/changelog?${url ? `url=${encodeURIComponent(url)}` : ""}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const text = data.text || "No changelog content.";
changelogCache = { version, text };
changelogBody.textContent = text;
}
changelogTitle.textContent = version ? `Changelog — ${version}` : "Changelog";
releaseModal?.classList.add("hidden");
changelogModal.classList.remove("hidden");
} catch (e) {
console.error("Changelog fetch failed", e);
showToast("Failed to load changelog", "error");
}
}
async function loadReleaseStatus(force = false) {
if (!releaseFlagTop) return;
const now = Date.now();
if (!force && now - releaseLastFetched < 60000 && !releaseBusyActive) {
return;
}
setReleaseChip({ status: "checking" });
try {
const data = await getReleaseStatus();
const {
current_version = "n/a",
latest_version = "n/a",
status = "unknown",
message = "",
auto_check = false,
progress = null,
channel = "dev",
} = data || {};
releaseChannel = channel || "dev";
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
window.__lastReleaseState = data;
const key = [status, progress, message].join("|");
if (key !== lastReleaseLogKey) {
logRelease(`Status: ${status}${progress ? " • " + progress : ""}${message ? " • " + message : ""}`);
lastReleaseLogKey = key;
}
releaseLastFetched = now;
if (status === "update_available" && message && message.startsWith("http")) {
lastChangelogUrl = message;
} else if (latest_version) {
lastChangelogUrl = `https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v${latest_version}/CHANGELOG-${latest_version}.txt`;
}
setReleaseChip(data);
if (releaseCurrent) releaseCurrent.textContent = current_version;
if (releaseLatest) releaseLatest.textContent = latest_version;
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
if (releaseProgress) releaseProgress.textContent = "";
if (status === "in_progress" && progress) {
showBusy("Working on update…", progress || "This can take up to a minute.");
pollReleaseStatus();
}
} catch (e) {
console.error("Failed to load release status", e);
setReleaseChip({ status: "error", message: "Failed to load" });
// surface via toast/log only; avoid inline red flashes
showToast("Failed to load release status", "error");
logRelease("Error: failed to load release status");
}
}
function pollReleaseStatus() {
let attempts = 0;
const maxAttempts = 30; // ~1 min at 1s
const started = Date.now();
const minWaitMs = 3000;
const tick = async () => {
attempts += 1;
await loadReleaseStatus(true);
const state = window.__lastReleaseState || {};
const tooSoon = Date.now() - started < minWaitMs;
if ((state.status === "in_progress" || tooSoon) && attempts < maxAttempts) {
setTimeout(tick, 1000);
} else {
releaseBusyActive = false;
hideBusy();
if (releaseProgress) releaseProgress.textContent = "";
// Only toast once per apply/rollback cycle
if (state.status === "up_to_date" && releaseBusyActive === false) {
const key = `ok-${state.current_version || ""}-${state.latest_version || ""}`;
if (lastReleaseToastKey !== key) {
lastReleaseToastKey = key;
showToast(state.message || "Update complete", "success");
}
logRelease("Update complete");
} else if (state.status === "error") {
const key = `err-${state.message || ""}`;
if (lastReleaseToastKey !== key) {
lastReleaseToastKey = key;
showToast(state.message || "Update failed", "error");
}
logRelease(`Error: ${state.message || "Update failed"}`);
}
}
};
tick();
}
function wireReleaseControls() {
releaseBtn?.addEventListener("click", () => {
releaseModal?.classList.remove("hidden");
loadReleaseStatus(true);
});
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
// Do not allow dismiss by clicking backdrop (consistency with other modals)
releaseModal?.addEventListener("click", (e) => {
if (e.target === releaseModal) {
e.stopPropagation();
}
});
releaseCheckBtn?.addEventListener("click", async () => {
try {
logRelease("Checking for updates…");
logUi("Update check requested");
await checkRelease();
await loadReleaseStatus(true);
const state = window.__lastReleaseState || {};
logRelease(
`Status: ${state.status || "unknown"}${state.message ? " • " + state.message : ""}`,
);
showToast("Checked for updates", "success");
} catch (e) {
showToast(e.error || "Check failed", "error");
logRelease(`Error: ${e.error || "Check failed"}`);
} finally {
if (releaseProgress) releaseProgress.textContent = "";
}
});
releaseApplyBtn?.addEventListener("click", async () => {
try {
lastReleaseToastKey = null;
logUi("Update apply requested");
const state = window.__lastReleaseState || {};
const { current_version, latest_version } = state;
const sameVersion =
current_version &&
latest_version &&
String(current_version) === String(latest_version);
if (sameVersion) {
const proceed = await confirmAction(
"Reinstall same version?",
`You are already on ${current_version}. Reinstall this version anyway?`,
);
if (!proceed) {
logRelease("Upgrade cancelled (same version).");
return;
}
}
releaseBusyActive = true;
showBusy("Updating Pi-Kit…", "Applying release. This can take up to a minute.");
logRelease("Starting upgrade…");
await applyRelease();
pollReleaseStatus();
showToast("Update started", "success");
} catch (e) {
showToast(e.error || "Update failed", "error");
logRelease(`Error: ${e.error || "Update failed"}`);
} finally {
if (releaseProgress) releaseProgress.textContent = "";
}
});
releaseRollbackBtn?.addEventListener("click", async () => {
try {
lastReleaseToastKey = null;
logUi("Rollback requested");
releaseBusyActive = true;
showBusy("Rolling back…", "Restoring previous backup.");
logRelease("Starting rollback…");
await rollbackRelease();
pollReleaseStatus();
showToast("Rollback started", "success");
} catch (e) {
showToast(e.error || "Rollback failed", "error");
logRelease(`Error: ${e.error || "Rollback failed"}`);
} finally {
if (releaseProgress) releaseProgress.textContent = "";
}
});
releaseAutoCheck?.addEventListener("change", async () => {
try {
await setReleaseAutoCheck(releaseAutoCheck.checked);
showToast("Auto-check preference saved", "success");
} catch (e) {
showToast(e.error || "Failed to save preference", "error");
releaseAutoCheck.checked = !releaseAutoCheck.checked;
}
});
releaseChannelToggle?.addEventListener("change", async () => {
try {
const chan = releaseChannelToggle.checked ? "dev" : "stable";
await setReleaseChannel(chan);
releaseChannel = chan;
logRelease(`Channel set to ${chan}`);
await loadReleaseStatus(true);
} catch (e) {
showToast(e.error || "Failed to save channel", "error");
releaseChannelToggle.checked = releaseChannel === "dev";
}
});
releaseChangelogBtn?.addEventListener("click", async () => {
const state = window.__lastReleaseState || {};
const { latest_version, message } = state;
const url = (message && message.startsWith("http") ? message : null) || lastChangelogUrl;
if (!url) {
showToast("No changelog URL available", "error");
return;
}
await showChangelog(latest_version, url);
});
releaseLogCopy?.addEventListener("click", async () => {
try {
const text = releaseLogLines.join("\n") || "No log entries yet.";
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for non-HTTPS contexts
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
showToast("Log copied", "success");
} catch (e) {
console.error("Copy failed", e);
showToast("Could not copy log", "error");
}
});
changelogClose?.addEventListener("click", () => {
changelogModal?.classList.add("hidden");
releaseModal?.classList.remove("hidden");
});
}
wireReleaseControls();
return {
refreshStatus: (force = false) => loadReleaseStatus(force),
logRelease,
};
}

View File

@@ -326,6 +326,16 @@ body {
border-color: rgba(225, 29, 72, 0.4);
background: rgba(225, 29, 72, 0.08);
}
.status-chip.chip-system {
color: #3b82f6;
border-color: rgba(59, 130, 246, 0.45);
background: rgba(59, 130, 246, 0.12);
}
.status-chip.chip-system {
color: #3b82f6;
border-color: rgba(59, 130, 246, 0.45);
background: rgba(59, 130, 246, 0.12);
}
.status-chip.chip-warm {
color: #d97706;
border-color: rgba(217, 119, 6, 0.35);
@@ -346,6 +356,11 @@ body {
border-color: rgba(34, 197, 94, 0.5);
color: #0f5132;
}
:root[data-theme="light"] .status-chip.chip-system {
background: rgba(59, 130, 246, 0.16);
border-color: rgba(59, 130, 246, 0.55);
color: #153e9f;
}
:root[data-theme="light"] .status-chip.chip-warm {
background: rgba(217, 119, 6, 0.16);
border-color: rgba(217, 119, 6, 0.5);
@@ -356,6 +371,73 @@ 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;
}
#diagModal pre.log-box {
max-height: 60vh;
min-height: 300px;
}
#releaseProgress {
display: none;
}
.updates-status {
display: none;
}
.updates-status.error {
display: block;
}
:root[data-theme="light"] .log-box {
background: rgba(12, 18, 32, 0.04);
}
.toast-container {
position: fixed;
bottom: 16px;
@@ -1324,6 +1406,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 +1436,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 +1454,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 +1474,31 @@ select:focus-visible,
#releaseModal .modal-card.wide {
max-width: 760px;
}
.release-versions {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
}
.release-versions > div {
flex: 1;
min-width: 0;
}
.release-versions .align-right {
text-align: right;
}
@media (max-width: 640px) {
.release-versions {
flex-direction: column;
gap: 8px;
}
.release-versions .align-right {
text-align: left;
}
}
.modal-card .status-msg {
overflow-wrap: anywhere;
}
.modal:not(.hidden) .modal-card {
transform: translateY(0) scale(1);
}

View File

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

View File

@@ -33,6 +33,9 @@
>
<span id="themeToggleIcon" aria-hidden="true">&#127769;</span>
</button>
<button id="diagLogBtn" class="ghost hidden" title="Diagnostics log (visible when enabled)">
Log
</button>
<button id="releaseBtn" class="ghost" title="Pi-Kit updates">
Update
</button>
@@ -87,6 +90,44 @@
</div>
</div>
<div id="changelogModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow">Changelog</p>
<h3 id="changelogTitle">Release notes</h3>
</div>
<button id="changelogClose" class="ghost icon-btn close-btn" title="Close changelog">
&times;
</button>
</div>
<pre id="changelogBody" class="log-box" aria-live="polite"></pre>
</div>
</div>
<div id="diagModal" class="modal hidden diag-log-modal">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow">Diagnostics</p>
<h3>Diagnostics log</h3>
<p class="hint">RAM-only; cleared on reboot/clear. Use toggles in Settings → Diagnostics to enable.</p>
</div>
<button id="diagClose" class="ghost icon-btn close-btn" title="Close diagnostics log">
&times;
</button>
</div>
<div class="control-actions wrap gap">
<button id="diagRefreshBtn" class="ghost">Refresh</button>
<button id="diagClearBtn" class="ghost">Clear</button>
<button id="diagCopyBtn" class="ghost">Copy</button>
<button id="diagDownloadBtn" class="ghost">Download</button>
<span id="diagStatusModal" class="hint quiet"></span>
</div>
<pre id="diagLogBox" 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 +143,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 +160,7 @@
Check
</button>
<button id="releaseApplyBtn" title="Download and install the latest release">
Download &amp; install
Upgrade
</button>
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
Rollback
@@ -127,8 +169,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 +240,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">
@@ -474,6 +548,31 @@
</div>
</div>
<div class="accordion">
<button class="accordion-toggle" data-target="acc-diag">
Diagnostics
</button>
<div class="accordion-body" id="acc-diag">
<p class="hint">
Temporary, RAM-only logs for debugging. Toggle on, choose debug for extra detail. Logs reset on reboot or clear. Use the Log button in the top bar (visible when diagnostics is enabled) to view, copy, download, or clear entries.
</p>
<div class="control-actions split-row">
<label class="checkbox-row inline tight">
<input type="checkbox" id="diagEnableToggle" />
<span>Enable diagnostics</span>
</label>
<label class="checkbox-row inline tight">
<input type="checkbox" id="diagDebugToggle" />
<span>Debug detail (includes UI clicks)</span>
</label>
</div>
<div class="control-actions">
<span id="diagStatus" class="hint quiet"></span>
</div>
<p class="hint quiet">Stored in RAM (max ~1MB server, ~500 entries client). No data written to disk.</p>
</div>
</div>
<div class="accordion">
<button class="accordion-toggle danger-btn" data-target="acc-reset">
Factory reset
@@ -678,7 +777,7 @@
</div>
</div>
<script type="module" src="assets/main.js"></script>
<script type="module" src="assets/main.js?v=20251213i"></script>
<div id="toastContainer" class="toast-container"></div>
</body>
</html>