Compare commits
30 Commits
v0.1.0-dev
...
v0.1.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
471e242427 | ||
|
|
357453eed4 | ||
|
|
25cc888b86 | ||
|
|
17ae87563f | ||
|
|
32503424e8 | ||
|
|
50be46df45 | ||
|
|
8c06962f62 | ||
|
|
2a439321d0 | ||
|
|
e993d19886 | ||
|
|
0e3b144cd7 | ||
|
|
98fbe1b96e | ||
|
|
8864df2b2c | ||
|
|
d49218409d | ||
|
|
35c83a918b | ||
|
|
c182eb179d | ||
|
|
650175913e | ||
|
|
5ee183d607 | ||
|
|
48be7a1c61 | ||
|
|
28acb94a6f | ||
|
|
2c60ba981b | ||
|
|
92e4ce88df | ||
|
|
c1eb7d0765 | ||
|
|
c66f7d78a0 | ||
|
|
c20ea57da6 | ||
|
|
4241a4da69 | ||
|
|
4e13b41bed | ||
|
|
d436d3013d | ||
|
|
b611d247b2 | ||
|
|
2bdd07b954 | ||
|
|
4461613339 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ out/
|
|||||||
|
|
||||||
# Stock images (large)
|
# Stock images (large)
|
||||||
images/stock/
|
images/stock/
|
||||||
|
|
||||||
|
# Local helpers
|
||||||
|
set_ready.sh
|
||||||
|
|||||||
469
pikit-api.py
469
pikit-api.py
@@ -4,8 +4,12 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
|||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import urllib.parse
|
||||||
import fcntl
|
import fcntl
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
import json as jsonlib
|
||||||
|
import io
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
HOST = "127.0.0.1"
|
HOST = "127.0.0.1"
|
||||||
PORT = 4000
|
PORT = 4000
|
||||||
@@ -47,6 +51,99 @@ API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py")
|
|||||||
BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
|
BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
|
||||||
TMP_ROOT = pathlib.Path("/var/tmp/pikit-update")
|
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):
|
def ensure_dir(path: pathlib.Path):
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -77,13 +174,26 @@ def normalize_path(path: str | None) -> str:
|
|||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def default_host():
|
||||||
|
"""Return preferred hostname (append .local if bare)."""
|
||||||
|
host = socket.gethostname()
|
||||||
|
if "." not in host:
|
||||||
|
host = f"{host}.local"
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
def dbg(msg):
|
def dbg(msg):
|
||||||
if not DEBUG_FLAG:
|
# Legacy debug file logging (when /boot/pikit-debug exists)
|
||||||
return
|
if DEBUG_FLAG:
|
||||||
API_LOG.parent.mkdir(parents=True, exist_ok=True)
|
API_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||||
ts = datetime.datetime.utcnow().isoformat()
|
ts = datetime.datetime.utcnow().isoformat()
|
||||||
with API_LOG.open("a") as f:
|
with API_LOG.open("a") as f:
|
||||||
f.write(f"[{ts}] {msg}\n")
|
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):
|
def set_ssh_password_auth(allow: bool):
|
||||||
@@ -124,7 +234,7 @@ def load_services():
|
|||||||
try:
|
try:
|
||||||
data = json.loads(SERVICE_JSON.read_text())
|
data = json.loads(SERVICE_JSON.read_text())
|
||||||
# Normalize entries: ensure url built from port if missing
|
# Normalize entries: ensure url built from port if missing
|
||||||
host = socket.gethostname()
|
host = default_host()
|
||||||
for svc in data:
|
for svc in data:
|
||||||
svc_path = normalize_path(svc.get("path"))
|
svc_path = normalize_path(svc.get("path"))
|
||||||
if svc_path:
|
if svc_path:
|
||||||
@@ -404,16 +514,8 @@ def set_updates_config(opts: dict):
|
|||||||
|
|
||||||
|
|
||||||
def detect_https(host, port):
|
def detect_https(host, port):
|
||||||
try:
|
"""Heuristic: known HTTPS ports or .local certs."""
|
||||||
import ssl
|
return int(port) in HTTPS_PORTS or str(host).lower().endswith(".local") or str(host).lower() == "pikit"
|
||||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
with socket.create_connection((host, int(port)), timeout=1.5) as sock:
|
|
||||||
with ctx.wrap_socket(sock, server_hostname=host):
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def factory_reset():
|
def factory_reset():
|
||||||
@@ -518,6 +620,7 @@ def load_update_state():
|
|||||||
"auto_check": False,
|
"auto_check": False,
|
||||||
"in_progress": False,
|
"in_progress": False,
|
||||||
"progress": None,
|
"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))
|
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):
|
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)
|
req = urllib.request.Request(target)
|
||||||
if AUTH_TOKEN:
|
token = _auth_token()
|
||||||
req.add_header("Authorization", f"token {AUTH_TOKEN}")
|
if token:
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
try:
|
||||||
resp = urllib.request.urlopen(req, timeout=10)
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
data = resp.read()
|
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
|
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):
|
def download_file(url: str, dest: pathlib.Path):
|
||||||
ensure_dir(dest.parent)
|
ensure_dir(dest.parent)
|
||||||
req = urllib.request.Request(url)
|
req = urllib.request.Request(url)
|
||||||
if AUTH_TOKEN:
|
token = _auth_token()
|
||||||
req.add_header("Authorization", f"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:
|
with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
|
||||||
shutil.copyfileobj(resp, f)
|
shutil.copyfileobj(resp, f)
|
||||||
return dest
|
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():
|
def check_for_update():
|
||||||
state = load_update_state()
|
state = load_update_state()
|
||||||
lock = acquire_lock()
|
lock = acquire_lock()
|
||||||
@@ -555,24 +777,32 @@ def check_for_update():
|
|||||||
state["message"] = "Another update is running"
|
state["message"] = "Another update is running"
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
return state
|
return state
|
||||||
|
diag_log("info", "Update check started", {"channel": state.get("channel") or "dev"})
|
||||||
state["in_progress"] = True
|
state["in_progress"] = True
|
||||||
state["progress"] = "Checking for updates…"
|
state["progress"] = "Checking for updates…"
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
try:
|
try:
|
||||||
manifest = fetch_manifest()
|
manifest = fetch_manifest_for_channel(state.get("channel") or "dev")
|
||||||
latest = manifest.get("version") or manifest.get("latest_version")
|
latest = manifest.get("version") or manifest.get("latest_version")
|
||||||
state["latest_version"] = latest
|
state["latest_version"] = latest
|
||||||
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
|
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"):
|
if latest and latest != state.get("current_version"):
|
||||||
state["status"] = "update_available"
|
state["status"] = "update_available"
|
||||||
state["message"] = manifest.get("changelog", "Update available")
|
state["message"] = manifest.get("changelog", "Update available")
|
||||||
else:
|
else:
|
||||||
state["status"] = "up_to_date"
|
state["status"] = "up_to_date"
|
||||||
state["message"] = "Up to date"
|
state["message"] = "Up to date"
|
||||||
|
diag_log("info", "Update check finished", {"status": state["status"], "latest": str(latest)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
state["status"] = "up_to_date"
|
state["status"] = "up_to_date"
|
||||||
state["message"] = f"Could not reach update server: {e}"
|
state["message"] = f"Could not reach update server: {e}"
|
||||||
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
|
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
|
||||||
|
diag_log("error", "Update check failed", {"error": str(e)})
|
||||||
finally:
|
finally:
|
||||||
state["in_progress"] = False
|
state["in_progress"] = False
|
||||||
state["progress"] = None
|
state["progress"] = None
|
||||||
@@ -602,13 +832,30 @@ def apply_update_stub():
|
|||||||
state["status"] = "in_progress"
|
state["status"] = "in_progress"
|
||||||
state["progress"] = "Starting update…"
|
state["progress"] = "Starting update…"
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
|
diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"})
|
||||||
|
|
||||||
try:
|
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")
|
latest = manifest.get("version") or manifest.get("latest_version")
|
||||||
if not latest:
|
if not latest:
|
||||||
raise RuntimeError("Manifest missing version")
|
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
|
# Paths
|
||||||
bundle_url = manifest.get("bundle") or manifest.get("url")
|
bundle_url = manifest.get("bundle") or manifest.get("url")
|
||||||
if not bundle_url:
|
if not bundle_url:
|
||||||
@@ -620,6 +867,7 @@ def apply_update_stub():
|
|||||||
state["progress"] = "Downloading release…"
|
state["progress"] = "Downloading release…"
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
download_file(bundle_url, bundle_path)
|
download_file(bundle_url, bundle_path)
|
||||||
|
diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)})
|
||||||
|
|
||||||
# Verify hash if provided
|
# Verify hash if provided
|
||||||
expected_hash = None
|
expected_hash = None
|
||||||
@@ -631,6 +879,7 @@ def apply_update_stub():
|
|||||||
got = sha256_file(bundle_path)
|
got = sha256_file(bundle_path)
|
||||||
if got.lower() != expected_hash.lower():
|
if got.lower() != expected_hash.lower():
|
||||||
raise RuntimeError("Bundle hash mismatch")
|
raise RuntimeError("Bundle hash mismatch")
|
||||||
|
diag_log("debug", "Bundle hash verified", {"expected": expected_hash})
|
||||||
|
|
||||||
state["progress"] = "Staging files…"
|
state["progress"] = "Staging files…"
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
@@ -638,19 +887,6 @@ def apply_update_stub():
|
|||||||
with tarfile.open(bundle_path, "r:gz") as tar:
|
with tarfile.open(bundle_path, "r:gz") as tar:
|
||||||
tar.extractall(stage_dir)
|
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
|
# Deploy from staging
|
||||||
staged_web = stage_dir / "pikit-web"
|
staged_web = stage_dir / "pikit-web"
|
||||||
if staged_web.exists():
|
if staged_web.exists():
|
||||||
@@ -674,32 +910,30 @@ def apply_update_stub():
|
|||||||
state["message"] = "Update installed"
|
state["message"] = "Update installed"
|
||||||
state["progress"] = None
|
state["progress"] = None
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
|
diag_log("info", "Update applied", {"version": str(latest)})
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
state["status"] = "error"
|
state["status"] = "error"
|
||||||
state["message"] = f"No release available ({e.code})"
|
state["message"] = f"No release available ({e.code})"
|
||||||
|
diag_log("error", "Update apply HTTP error", {"code": e.code})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
state["status"] = "error"
|
state["status"] = "error"
|
||||||
state["message"] = f"Update failed: {e}"
|
state["message"] = f"Update failed: {e}"
|
||||||
state["progress"] = None
|
state["progress"] = None
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
|
diag_log("error", "Update apply failed", {"error": str(e)})
|
||||||
# Attempt rollback if backup exists
|
# Attempt rollback if backup exists
|
||||||
backups = sorted(BACKUP_ROOT.glob("*"), reverse=True)
|
backup = choose_rollback_backup()
|
||||||
if backups:
|
if backup:
|
||||||
try:
|
try:
|
||||||
latest_backup = backups[0]
|
restore_backup(backup)
|
||||||
if (latest_backup / "pikit-web").exists():
|
state["current_version"] = read_current_version()
|
||||||
shutil.rmtree(WEB_ROOT, ignore_errors=True)
|
state["message"] += f" (rolled back to backup {backup.name})"
|
||||||
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)"
|
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
|
diag_log("info", "Rollback after failed update", {"backup": backup.name})
|
||||||
except Exception as re:
|
except Exception as re:
|
||||||
state["message"] += f" (rollback failed: {re})"
|
state["message"] += f" (rollback failed: {re})"
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
|
diag_log("error", "Rollback after failed update failed", {"error": str(re)})
|
||||||
finally:
|
finally:
|
||||||
state["in_progress"] = False
|
state["in_progress"] = False
|
||||||
state["progress"] = None
|
state["progress"] = None
|
||||||
@@ -721,8 +955,9 @@ def rollback_update_stub():
|
|||||||
state["status"] = "in_progress"
|
state["status"] = "in_progress"
|
||||||
state["progress"] = "Rolling back…"
|
state["progress"] = "Rolling back…"
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
backups = sorted(BACKUP_ROOT.glob("*"), reverse=True)
|
diag_log("info", "Rollback started")
|
||||||
if not backups:
|
backup = choose_rollback_backup()
|
||||||
|
if not backup:
|
||||||
state["status"] = "error"
|
state["status"] = "error"
|
||||||
state["message"] = "No backup available to rollback."
|
state["message"] = "No backup available to rollback."
|
||||||
state["in_progress"] = False
|
state["in_progress"] = False
|
||||||
@@ -730,21 +965,19 @@ def rollback_update_stub():
|
|||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
release_lock(lock)
|
release_lock(lock)
|
||||||
return state
|
return state
|
||||||
target = backups[0]
|
|
||||||
try:
|
try:
|
||||||
if (target / "pikit-web").exists():
|
restore_backup(backup)
|
||||||
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)
|
|
||||||
state["status"] = "up_to_date"
|
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:
|
except Exception as e:
|
||||||
state["status"] = "error"
|
state["status"] = "error"
|
||||||
state["message"] = f"Rollback failed: {e}"
|
state["message"] = f"Rollback failed: {e}"
|
||||||
|
diag_log("error", "Rollback failed", {"error": str(e)})
|
||||||
state["in_progress"] = False
|
state["in_progress"] = False
|
||||||
state["progress"] = None
|
state["progress"] = None
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
@@ -795,12 +1028,70 @@ def release_lock(lockfile):
|
|||||||
def prune_backups(keep: int = 2):
|
def prune_backups(keep: int = 2):
|
||||||
if keep < 1:
|
if keep < 1:
|
||||||
keep = 1
|
keep = 1
|
||||||
ensure_dir(BACKUP_ROOT)
|
backups = list_backups()
|
||||||
backups = sorted([p for p in BACKUP_ROOT.iterdir() if p.is_dir()], reverse=True)
|
|
||||||
for old in backups[keep:]:
|
for old in backups[keep:]:
|
||||||
shutil.rmtree(old, ignore_errors=True)
|
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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
"""Minimal JSON API for the dashboard (status, services, updates, reset)."""
|
"""Minimal JSON API for the dashboard (status, services, updates, reset)."""
|
||||||
def _send(self, code, data):
|
def _send(self, code, data):
|
||||||
@@ -885,6 +1176,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if port:
|
if port:
|
||||||
svc["online"] = port_online("127.0.0.1", port)
|
svc["online"] = port_online("127.0.0.1", port)
|
||||||
svc["firewall_open"] = ufw_status_allows(port)
|
svc["firewall_open"] = ufw_status_allows(port)
|
||||||
|
# Rebuild URL with preferred host (adds .local)
|
||||||
|
host = default_host()
|
||||||
|
path = normalize_path(svc.get("path"))
|
||||||
|
scheme = svc.get("scheme") or ("https" if detect_https(host, port) else "http")
|
||||||
|
svc["scheme"] = scheme
|
||||||
|
svc["url"] = f"{scheme}://{host}:{port}{path}"
|
||||||
services.append(svc)
|
services.append(svc)
|
||||||
self._send(200, {"services": services})
|
self._send(200, {"services": services})
|
||||||
elif self.path.startswith("/api/updates/auto"):
|
elif self.path.startswith("/api/updates/auto"):
|
||||||
@@ -896,7 +1193,27 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif self.path.startswith("/api/update/status"):
|
elif self.path.startswith("/api/update/status"):
|
||||||
state = load_update_state()
|
state = load_update_state()
|
||||||
state["current_version"] = read_current_version()
|
state["current_version"] = read_current_version()
|
||||||
|
state["channel"] = state.get("channel", os.environ.get("PIKIT_CHANNEL", "dev"))
|
||||||
self._send(200, state)
|
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:
|
else:
|
||||||
self._send(404, {"error": "not found"})
|
self._send(404, {"error": "not found"})
|
||||||
|
|
||||||
@@ -907,6 +1224,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if payload.get("confirm") == "YES":
|
if payload.get("confirm") == "YES":
|
||||||
self._send(200, {"message": "Resetting and rebooting..."})
|
self._send(200, {"message": "Resetting and rebooting..."})
|
||||||
dbg("Factory reset triggered via API")
|
dbg("Factory reset triggered via API")
|
||||||
|
diag_log("info", "Factory reset requested")
|
||||||
factory_reset()
|
factory_reset()
|
||||||
else:
|
else:
|
||||||
self._send(400, {"error": "type YES to confirm"})
|
self._send(400, {"error": "type YES to confirm"})
|
||||||
@@ -916,14 +1234,17 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
set_auto_updates(enable)
|
set_auto_updates(enable)
|
||||||
dbg(f"Auto updates set to {enable}")
|
dbg(f"Auto updates set to {enable}")
|
||||||
state = auto_updates_state()
|
state = auto_updates_state()
|
||||||
|
diag_log("info", "Auto updates toggled", {"enabled": enable})
|
||||||
return self._send(200, {"enabled": state.get("enabled", False), "details": state})
|
return self._send(200, {"enabled": state.get("enabled", False), "details": state})
|
||||||
if self.path.startswith("/api/updates/config"):
|
if self.path.startswith("/api/updates/config"):
|
||||||
try:
|
try:
|
||||||
cfg = set_updates_config(payload or {})
|
cfg = set_updates_config(payload or {})
|
||||||
dbg(f"Update settings applied: {cfg}")
|
dbg(f"Update settings applied: {cfg}")
|
||||||
|
diag_log("info", "Update settings saved", cfg)
|
||||||
return self._send(200, cfg)
|
return self._send(200, cfg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
dbg(f"Failed to apply updates config: {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)})
|
return self._send(500, {"error": str(e)})
|
||||||
if self.path.startswith("/api/update/check"):
|
if self.path.startswith("/api/update/check"):
|
||||||
state = check_for_update()
|
state = check_for_update()
|
||||||
@@ -947,7 +1268,28 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
state = load_update_state()
|
state = load_update_state()
|
||||||
state["auto_check"] = bool(payload.get("enable"))
|
state["auto_check"] = bool(payload.get("enable"))
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
|
diag_log("info", "Release auto-check toggled", {"enabled": state["auto_check"]})
|
||||||
return self._send(200, 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)
|
||||||
|
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"):
|
if self.path.startswith("/api/services/add"):
|
||||||
name = payload.get("name")
|
name = payload.get("name")
|
||||||
port = int(payload.get("port", 0))
|
port = int(payload.get("port", 0))
|
||||||
@@ -958,7 +1300,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
services = load_services()
|
services = load_services()
|
||||||
if any(s.get("port") == port for s in services):
|
if any(s.get("port") == port for s in services):
|
||||||
return self._send(400, {"error": "port already exists"})
|
return self._send(400, {"error": "port already exists"})
|
||||||
host = socket.gethostname()
|
host = default_host()
|
||||||
scheme = payload.get("scheme")
|
scheme = payload.get("scheme")
|
||||||
if scheme not in ("http", "https"):
|
if scheme not in ("http", "https"):
|
||||||
scheme = "https" if detect_https(host, port) else "http"
|
scheme = "https" if detect_https(host, port) else "http"
|
||||||
@@ -981,6 +1323,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
allow_port_lan(port)
|
allow_port_lan(port)
|
||||||
except FirewallToolMissing as e:
|
except FirewallToolMissing as e:
|
||||||
return self._send(500, {"error": str(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"})
|
return self._send(200, {"services": services, "message": f"Added {name} on port {port} and opened LAN firewall"})
|
||||||
if self.path.startswith("/api/services/remove"):
|
if self.path.startswith("/api/services/remove"):
|
||||||
port = int(payload.get("port", 0))
|
port = int(payload.get("port", 0))
|
||||||
@@ -994,6 +1337,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except FirewallToolMissing as e:
|
except FirewallToolMissing as e:
|
||||||
return self._send(500, {"error": str(e)})
|
return self._send(500, {"error": str(e)})
|
||||||
save_services(services)
|
save_services(services)
|
||||||
|
diag_log("info", "Service removed", {"port": port})
|
||||||
return self._send(200, {"services": services, "message": f"Removed service on port {port}"})
|
return self._send(200, {"services": services, "message": f"Removed service on port {port}"})
|
||||||
if self.path.startswith("/api/services/update"):
|
if self.path.startswith("/api/services/update"):
|
||||||
port = int(payload.get("port", 0))
|
port = int(payload.get("port", 0))
|
||||||
@@ -1030,7 +1374,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
svc["port"] = new_port_int
|
svc["port"] = new_port_int
|
||||||
target_port = new_port_int
|
target_port = new_port_int
|
||||||
port_changed = True
|
port_changed = True
|
||||||
host = socket.gethostname()
|
host = default_host()
|
||||||
if new_path is not None:
|
if new_path is not None:
|
||||||
path = normalize_path(new_path)
|
path = normalize_path(new_path)
|
||||||
if path:
|
if path:
|
||||||
@@ -1071,6 +1415,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not updated:
|
if not updated:
|
||||||
return self._send(404, {"error": "service not found"})
|
return self._send(404, {"error": "service not found"})
|
||||||
save_services(services)
|
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"})
|
return self._send(200, {"services": services, "message": "Service updated"})
|
||||||
self._send(404, {"error": "not found"})
|
self._send(404, {"error": "not found"})
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ export const setReleaseAutoCheck = (enable) =>
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ enable }),
|
body: JSON.stringify({ enable }),
|
||||||
});
|
});
|
||||||
|
export const setReleaseChannel = (channel) =>
|
||||||
|
api("/api/update/channel", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ channel }),
|
||||||
|
});
|
||||||
|
|
||||||
export const triggerReset = (confirm) =>
|
export const triggerReset = (confirm) =>
|
||||||
api("/api/reset", {
|
api("/api/reset", {
|
||||||
@@ -112,3 +117,15 @@ export const removeService = ({ port }) =>
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ port }),
|
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",
|
||||||
|
});
|
||||||
|
|||||||
4
pikit-web/assets/diaglog.css
Normal file
4
pikit-web/assets/diaglog.css
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.diag-log-modal .log-box {
|
||||||
|
max-height: 60vh;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
225
pikit-web/assets/diaglog.js
Normal file
225
pikit-web/assets/diaglog.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
// 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 (err2) {
|
||||||
|
toast?.(err2.error || "Diagnostics still failing", "error");
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
if (logButton) logButton.classList.toggle("hidden", !uiEnabled);
|
||||||
|
if (!uiEnabled && modal) modal.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
// Immediately reflect empty log in UI, then refresh from server
|
||||||
|
if (logBox) logBox.textContent = "";
|
||||||
|
render([]);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
// Entry point for the dashboard: wires UI events, pulls status, and initializes
|
// Entry point for the dashboard: wires UI events, pulls status, and initializes
|
||||||
// feature modules (services, settings, stats).
|
// feature modules (services, settings, stats).
|
||||||
import {
|
import { getStatus, triggerReset } from "./api.js";
|
||||||
getStatus,
|
|
||||||
triggerReset,
|
|
||||||
getReleaseStatus,
|
|
||||||
checkRelease,
|
|
||||||
applyRelease,
|
|
||||||
rollbackRelease,
|
|
||||||
setReleaseAutoCheck,
|
|
||||||
} from "./api.js";
|
|
||||||
import { placeholderStatus, renderStats } from "./status.js";
|
import { placeholderStatus, renderStats } from "./status.js";
|
||||||
import { initServiceControls, renderServices } from "./services.js";
|
import { initServiceControls, renderServices } from "./services.js";
|
||||||
import { initSettings } from "./settings.js";
|
import { initSettings } from "./settings.js";
|
||||||
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
|
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
|
||||||
|
import { initReleaseUI } from "./releases.js?v=20251213h";
|
||||||
|
import { initDiagUI, logUi } from "./diaglog.js?v=20251213j";
|
||||||
|
|
||||||
const servicesGrid = document.getElementById("servicesGrid");
|
const servicesGrid = document.getElementById("servicesGrid");
|
||||||
const heroStats = document.getElementById("heroStats");
|
const heroStats = document.getElementById("heroStats");
|
||||||
@@ -83,17 +77,6 @@ const menuClose = document.getElementById("menuClose");
|
|||||||
const advBtn = document.getElementById("advBtn");
|
const advBtn = document.getElementById("advBtn");
|
||||||
const advModal = document.getElementById("advModal");
|
const advModal = document.getElementById("advModal");
|
||||||
const advClose = document.getElementById("advClose");
|
const advClose = document.getElementById("advClose");
|
||||||
const releaseBtn = document.getElementById("releaseBtn");
|
|
||||||
const releaseModal = document.getElementById("releaseModal");
|
|
||||||
const releaseClose = document.getElementById("releaseClose");
|
|
||||||
const releaseCurrent = document.getElementById("releaseCurrent");
|
|
||||||
const releaseLatest = document.getElementById("releaseLatest");
|
|
||||||
const releaseStatusMsg = document.getElementById("releaseStatusMsg");
|
|
||||||
const releaseProgress = document.getElementById("releaseProgress");
|
|
||||||
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
|
|
||||||
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
|
|
||||||
const releaseRollbackBtn = document.getElementById("releaseRollbackBtn");
|
|
||||||
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
|
|
||||||
|
|
||||||
const helpBtn = document.getElementById("helpBtn");
|
const helpBtn = document.getElementById("helpBtn");
|
||||||
const helpModal = document.getElementById("helpModal");
|
const helpModal = document.getElementById("helpModal");
|
||||||
@@ -106,6 +89,27 @@ const busyTitle = document.getElementById("busyTitle");
|
|||||||
const busyText = document.getElementById("busyText");
|
const busyText = document.getElementById("busyText");
|
||||||
const toastContainer = document.getElementById("toastContainer");
|
const toastContainer = document.getElementById("toastContainer");
|
||||||
const readyOverlay = document.getElementById("readyOverlay");
|
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_POS_KEY = "pikit-toast-pos";
|
||||||
const TOAST_ANIM_KEY = "pikit-toast-anim";
|
const TOAST_ANIM_KEY = "pikit-toast-anim";
|
||||||
@@ -128,6 +132,8 @@ let toastAnimation = "slide-in";
|
|||||||
let toastDurationMs = 5000;
|
let toastDurationMs = 5000;
|
||||||
let toastSpeedMs = 300;
|
let toastSpeedMs = 300;
|
||||||
let fontChoice = "redhat";
|
let fontChoice = "redhat";
|
||||||
|
let releaseUI = null;
|
||||||
|
let lastStatusData = null;
|
||||||
|
|
||||||
function applyToastSettings() {
|
function applyToastSettings() {
|
||||||
if (!toastContainer) return;
|
if (!toastContainer) return;
|
||||||
@@ -313,6 +319,7 @@ function setUpdatesUI(enabled) {
|
|||||||
async function loadStatus() {
|
async function loadStatus() {
|
||||||
try {
|
try {
|
||||||
const data = await getStatus();
|
const data = await getStatus();
|
||||||
|
lastStatusData = data;
|
||||||
renderStats(heroStats, data);
|
renderStats(heroStats, data);
|
||||||
renderServices(servicesGrid, data.services, { openAddService });
|
renderServices(servicesGrid, data.services, { openAddService });
|
||||||
const updatesEnabled =
|
const updatesEnabled =
|
||||||
@@ -350,11 +357,15 @@ async function loadStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pull Pi-Kit release status after core status
|
// Pull Pi-Kit release status after core status
|
||||||
loadReleaseStatus();
|
releaseUI?.refreshStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
logUi(`Status refresh failed: ${e?.message || e}`, "error");
|
||||||
|
if (!lastStatusData) {
|
||||||
renderStats(heroStats, placeholderStatus);
|
renderStats(heroStats, placeholderStatus);
|
||||||
}
|
}
|
||||||
|
setTimeout(loadStatus, 2000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTempFlag(tempC) {
|
function setTempFlag(tempC) {
|
||||||
@@ -379,72 +390,12 @@ function setTempFlag(tempC) {
|
|||||||
|
|
||||||
function updatesFlagEl(enabled) {
|
function updatesFlagEl(enabled) {
|
||||||
if (!updatesFlagTop) return;
|
if (!updatesFlagTop) return;
|
||||||
updatesFlagTop.textContent = "Auto updates";
|
const labelOn = "System updates: On";
|
||||||
updatesFlagTop.classList.remove("chip-on", "chip-off");
|
const labelOff = "System updates: Off";
|
||||||
if (enabled === true) updatesFlagTop.classList.add("chip-on");
|
updatesFlagTop.textContent =
|
||||||
else if (enabled === false) updatesFlagTop.classList.add("chip-off");
|
enabled === true ? labelOn : enabled === false ? labelOff : "System updates";
|
||||||
}
|
updatesFlagTop.className = "status-chip quiet chip-system";
|
||||||
|
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireModals() {
|
function wireModals() {
|
||||||
@@ -460,88 +411,6 @@ function wireModals() {
|
|||||||
addServiceModal?.addEventListener("click", (e) => {
|
addServiceModal?.addEventListener("click", (e) => {
|
||||||
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
||||||
});
|
});
|
||||||
releaseBtn?.addEventListener("click", () => {
|
|
||||||
releaseModal?.classList.remove("hidden");
|
|
||||||
loadReleaseStatus();
|
|
||||||
});
|
|
||||||
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
|
|
||||||
releaseModal?.addEventListener("click", (e) => {
|
|
||||||
if (e.target === releaseModal) releaseModal.classList.add("hidden");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireReleaseControls() {
|
|
||||||
releaseCheckBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
if (releaseProgress) releaseProgress.textContent = "Checking for updates…";
|
|
||||||
await checkRelease();
|
|
||||||
await loadReleaseStatus();
|
|
||||||
showToast("Checked for updates", "success");
|
|
||||||
} catch (e) {
|
|
||||||
showToast(e.error || "Check failed", "error");
|
|
||||||
} finally {
|
|
||||||
if (releaseProgress) releaseProgress.textContent = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
releaseApplyBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
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.") {
|
function showBusy(title = "Working…", text = "This may take a few seconds.") {
|
||||||
@@ -556,6 +425,27 @@ function hideBusy() {
|
|||||||
busyOverlay?.classList.add("hidden");
|
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
|
// Testing hook
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.__pikitTest = window.__pikitTest || {};
|
window.__pikitTest = window.__pikitTest || {};
|
||||||
@@ -617,9 +507,15 @@ if (typeof window !== "undefined") {
|
|||||||
function main() {
|
function main() {
|
||||||
applyTooltips();
|
applyTooltips();
|
||||||
wireModals();
|
wireModals();
|
||||||
wireReleaseControls();
|
|
||||||
wireResetAndUpdates();
|
wireResetAndUpdates();
|
||||||
wireAccordions();
|
wireAccordions();
|
||||||
|
releaseUI = initReleaseUI({
|
||||||
|
showToast,
|
||||||
|
showBusy,
|
||||||
|
hideBusy,
|
||||||
|
confirmAction,
|
||||||
|
logUi,
|
||||||
|
});
|
||||||
loadToastSettings();
|
loadToastSettings();
|
||||||
|
|
||||||
if (advClose) {
|
if (advClose) {
|
||||||
@@ -682,6 +578,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
|
// Toast controls
|
||||||
toastPosSelect?.addEventListener("change", () => {
|
toastPosSelect?.addEventListener("change", () => {
|
||||||
const val = toastPosSelect.value;
|
const val = toastPosSelect.value;
|
||||||
|
|||||||
384
pikit-web/assets/releases.js
Normal file
384
pikit-web/assets/releases.js
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
// Mirror into global diagnostics log (frontend side)
|
||||||
|
const lvl = msg.toLowerCase().startsWith("error") ? "error" : "info";
|
||||||
|
logUi(`Update: ${msg}`, lvl);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// During an update/rollback the API may restart; retry quietly.
|
||||||
|
if (releaseBusyActive) {
|
||||||
|
setTimeout(() => loadReleaseStatus(true), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Failed to load release status", e);
|
||||||
|
setReleaseChip({ status: "error", message: "Failed to load" });
|
||||||
|
// surface via toast/log only once
|
||||||
|
logRelease("Error: failed to load release status");
|
||||||
|
showToast("Failed to load release status", "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 = "";
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -326,6 +326,16 @@ body {
|
|||||||
border-color: rgba(225, 29, 72, 0.4);
|
border-color: rgba(225, 29, 72, 0.4);
|
||||||
background: rgba(225, 29, 72, 0.08);
|
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 {
|
.status-chip.chip-warm {
|
||||||
color: #d97706;
|
color: #d97706;
|
||||||
border-color: rgba(217, 119, 6, 0.35);
|
border-color: rgba(217, 119, 6, 0.35);
|
||||||
@@ -346,6 +356,11 @@ body {
|
|||||||
border-color: rgba(34, 197, 94, 0.5);
|
border-color: rgba(34, 197, 94, 0.5);
|
||||||
color: #0f5132;
|
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 {
|
:root[data-theme="light"] .status-chip.chip-warm {
|
||||||
background: rgba(217, 119, 6, 0.16);
|
background: rgba(217, 119, 6, 0.16);
|
||||||
border-color: rgba(217, 119, 6, 0.5);
|
border-color: rgba(217, 119, 6, 0.5);
|
||||||
@@ -356,6 +371,73 @@ body {
|
|||||||
border-color: rgba(225, 29, 72, 0.55);
|
border-color: rgba(225, 29, 72, 0.55);
|
||||||
color: #7a1028;
|
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 {
|
.toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
@@ -1324,6 +1406,15 @@ select:focus-visible,
|
|||||||
transition: opacity 0.18s ease;
|
transition: opacity 0.18s ease;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
.modal#changelogModal {
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
.modal#changelogModal {
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
.modal#changelogModal {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
.modal.hidden {
|
.modal.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1345,7 +1436,7 @@ select:focus-visible,
|
|||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
.modal-card {
|
.modal-card {
|
||||||
transform: translateY(6px) scale(0.99);
|
transform: translateY(6px) scale(0.99);
|
||||||
@@ -1363,8 +1454,12 @@ select:focus-visible,
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-card.wide .help-body {
|
.modal-card.wide .help-body,
|
||||||
padding: 0 18px 18px;
|
.modal-card.wide .controls {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
.modal-card.wide .control-card {
|
||||||
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Extra breathing room for custom add-service modal */
|
/* Extra breathing room for custom add-service modal */
|
||||||
@@ -1379,6 +1474,31 @@ select:focus-visible,
|
|||||||
#releaseModal .modal-card.wide {
|
#releaseModal .modal-card.wide {
|
||||||
max-width: 760px;
|
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 {
|
.modal:not(.hidden) .modal-card {
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,14 +115,15 @@ export function initUpdateSettings({
|
|||||||
|
|
||||||
function showMessage(text, isError = false) {
|
function showMessage(text, isError = false) {
|
||||||
if (!msgEl) return;
|
if (!msgEl) return;
|
||||||
msgEl.textContent = text || "";
|
// Only surface inline text for errors; successes go to toast only.
|
||||||
msgEl.classList.toggle("error", isError);
|
if (isError) {
|
||||||
|
msgEl.textContent = text || "Something went wrong";
|
||||||
if (text) {
|
msgEl.classList.add("error");
|
||||||
if (toast && msgEl.id !== "updatesMsg") toast(text, isError ? "error" : "success");
|
} else {
|
||||||
setTimeout(() => (msgEl.textContent = ""), 2500);
|
msgEl.textContent = "";
|
||||||
|
msgEl.classList.remove("error");
|
||||||
}
|
}
|
||||||
|
if (toast) toast(text || (isError ? "Error" : ""), isError ? "error" : "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentConfigFromForm() {
|
function currentConfigFromForm() {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@
|
|||||||
>
|
>
|
||||||
<span id="themeToggleIcon" aria-hidden="true">🌙</span>
|
<span id="themeToggleIcon" aria-hidden="true">🌙</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="diagLogBtn" class="ghost hidden" title="Diagnostics log (visible when enabled)">
|
||||||
|
Log
|
||||||
|
</button>
|
||||||
<button id="releaseBtn" class="ghost" title="Pi-Kit updates">
|
<button id="releaseBtn" class="ghost" title="Pi-Kit updates">
|
||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
@@ -87,6 +90,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="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">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-actions wrap gap">
|
||||||
|
<button id="diagRefreshBtn" class="ghost" title="Refresh diagnostics log">Refresh</button>
|
||||||
|
<button id="diagClearBtn" class="ghost" title="Clear diagnostics log">Clear</button>
|
||||||
|
<button id="diagCopyBtn" class="ghost" title="Copy diagnostics to clipboard">Copy</button>
|
||||||
|
<button id="diagDownloadBtn" class="ghost" title="Download diagnostics as text">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 id="releaseModal" class="modal hidden">
|
||||||
<div class="modal-card wide">
|
<div class="modal-card wide">
|
||||||
<div class="panel-header sticky">
|
<div class="panel-header sticky">
|
||||||
@@ -102,15 +143,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls column">
|
<div class="controls column">
|
||||||
<div class="control-card">
|
<div class="control-card release-versions">
|
||||||
<div>
|
<div>
|
||||||
<p class="hint quiet">Current version</p>
|
<p class="hint quiet">Current version</p>
|
||||||
<h3 id="releaseCurrent">n/a</h3>
|
<h3 id="releaseCurrent">n/a</h3>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="align-right">
|
||||||
<p class="hint quiet">Latest available</p>
|
<p class="hint quiet">Latest available</p>
|
||||||
<h3 id="releaseLatest">—</h3>
|
<h3 id="releaseLatest">—</h3>
|
||||||
<p id="releaseStatusMsg" class="hint status-msg"></p>
|
<p id="releaseStatusMsg" class="hint status-msg"></p>
|
||||||
|
<button id="releaseChangelogBtn" class="ghost small" title="View changelog">Changelog</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-actions split-row">
|
<div class="control-actions split-row">
|
||||||
@@ -118,7 +160,7 @@
|
|||||||
Check
|
Check
|
||||||
</button>
|
</button>
|
||||||
<button id="releaseApplyBtn" title="Download and install the latest release">
|
<button id="releaseApplyBtn" title="Download and install the latest release">
|
||||||
Download & install
|
Upgrade
|
||||||
</button>
|
</button>
|
||||||
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
|
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
|
||||||
Rollback
|
Rollback
|
||||||
@@ -127,8 +169,24 @@
|
|||||||
<input type="checkbox" id="releaseAutoCheck" />
|
<input type="checkbox" id="releaseAutoCheck" />
|
||||||
<span>Auto-check daily</span>
|
<span>Auto-check daily</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="checkbox-row inline">
|
||||||
|
<input type="checkbox" id="releaseChannelToggle" />
|
||||||
|
<span>Allow dev builds</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="releaseProgress" class="hint status-msg"></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,6 +240,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="addServiceModal" class="modal hidden">
|
||||||
<div class="modal-card wide">
|
<div class="modal-card wide">
|
||||||
<div class="panel-header sticky">
|
<div class="panel-header sticky">
|
||||||
@@ -202,6 +276,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
id="svcName"
|
id="svcName"
|
||||||
placeholder="Service name"
|
placeholder="Service name"
|
||||||
|
title="Service name"
|
||||||
maxlength="32"
|
maxlength="32"
|
||||||
/>
|
/>
|
||||||
<p class="hint quiet">Service name: max 32 characters.</p>
|
<p class="hint quiet">Service name: max 32 characters.</p>
|
||||||
@@ -211,11 +286,13 @@
|
|||||||
placeholder="Port (e.g. 8080)"
|
placeholder="Port (e.g. 8080)"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
|
title="Service port"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="svcPath"
|
id="svcPath"
|
||||||
placeholder="Optional path (e.g. /admin)"
|
placeholder="Optional path (e.g. /admin)"
|
||||||
|
title="Optional path (e.g. /admin)"
|
||||||
/>
|
/>
|
||||||
<div class="control-row split">
|
<div class="control-row split">
|
||||||
<label class="checkbox-row">
|
<label class="checkbox-row">
|
||||||
@@ -234,11 +311,13 @@
|
|||||||
id="svcNotice"
|
id="svcNotice"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Optional notice (shown on card)"
|
placeholder="Optional notice (shown on card)"
|
||||||
|
title="Optional notice shown on the service card"
|
||||||
></textarea>
|
></textarea>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="svcNoticeLink"
|
id="svcNoticeLink"
|
||||||
placeholder="Optional link for more info"
|
placeholder="Optional link for more info"
|
||||||
|
title="Optional link for more info"
|
||||||
/>
|
/>
|
||||||
<div class="control-actions">
|
<div class="control-actions">
|
||||||
<button id="svcAddBtn" title="Add service and open port on LAN">
|
<button id="svcAddBtn" title="Add service and open port on LAN">
|
||||||
@@ -474,6 +553,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="accordion">
|
||||||
<button class="accordion-toggle danger-btn" data-target="acc-reset">
|
<button class="accordion-toggle danger-btn" data-target="acc-reset">
|
||||||
Factory reset
|
Factory reset
|
||||||
@@ -678,7 +782,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="assets/main.js"></script>
|
<script type="module" src="assets/main.js?v=20251213j"></script>
|
||||||
<div id="toastContainer" class="toast-container"></div>
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
142
pikit-web/onboarding/index.html
Normal file
142
pikit-web/onboarding/index.html
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Welcome to your Pi-Kit</title>
|
||||||
|
<link rel="stylesheet" href="/style.css?v=2" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<header>
|
||||||
|
<div class="dot"></div>
|
||||||
|
<h1>Welcome to your Pi-Kit</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="welcome">Great news — you’re already on your Pi-Kit and it’s responding.</p>
|
||||||
|
<p class="subtle">
|
||||||
|
Everything stays on your local network. Let’s move you to the secure (HTTPS) dashboard so you
|
||||||
|
can manage Pi-Kit safely.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge"><span class="dot"></span> Local-only traffic</span>
|
||||||
|
<span class="badge"><span class="dot"></span> Covers the Pi-Kit dashboard</span>
|
||||||
|
<span class="badge"><span class="dot"></span> HTTPS ready once trusted</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="actions">
|
||||||
|
<button id="continueBtn">Continue to secure dashboard</button>
|
||||||
|
<a class="ghost" id="downloadCa" href="http://pikit.local/assets/pikit-ca.crt" download>
|
||||||
|
Download Pi-Kit CA
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="steps">
|
||||||
|
<h3>Why switch to HTTPS?</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Encrypts traffic on your LAN so no one can snoop your Pi-Kit dashboard.</li>
|
||||||
|
<li>Stops mixed-content / “not secure” browser warnings.</li>
|
||||||
|
<li>Needed for some browser features (clipboard, notifications, service workers).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="steps">
|
||||||
|
<h3>If you see a warning</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Brave/Chrome: click <strong>Advanced</strong> → <strong>Proceed</strong>.</li>
|
||||||
|
<li>Firefox: click <strong>Advanced</strong> → <strong>Accept the Risk & Continue</strong>.</li>
|
||||||
|
</ul>
|
||||||
|
<p>This warning is expected the first time. It’s safe for your own Pi on your own network.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="steps">
|
||||||
|
<h3>Install the Pi-Kit CA (recommended, one-time)</h3>
|
||||||
|
<p>This removes future warnings for the Pi-Kit dashboard.</p>
|
||||||
|
<details>
|
||||||
|
<summary>Windows</summary>
|
||||||
|
<p>Run <strong>mmc</strong> → Add/Remove Snap-in → Certificates (Computer) → Trusted Root CAs → Import <em>pikit-ca.crt</em>.</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>macOS</summary>
|
||||||
|
<p>Double-click <em>pikit-ca.crt</em> → Always Trust.</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Linux (Arch / Manjaro / Garuda, etc.)</summary>
|
||||||
|
<code id="archCmd">curl -s http://pikit.local/assets/pikit-ca.crt -o /tmp/pikit-ca.crt && sudo install -m644 /tmp/pikit-ca.crt /etc/ca-certificates/trust-source/anchors/ && sudo trust extract-compat</code>
|
||||||
|
<button class="copy" data-target="archCmd">Copy</button>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Linux (Debian / Ubuntu)</summary>
|
||||||
|
<code id="debCmd">curl -s http://pikit.local/assets/pikit-ca.crt -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /usr/local/share/ca-certificates/pikit-ca.crt && sudo update-ca-certificates</code>
|
||||||
|
<button class="copy" data-target="debCmd">Copy</button>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Linux (Fedora)</summary>
|
||||||
|
<code id="fedoraCmd">curl -s http://pikit.local/assets/pikit-ca.crt -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /etc/pki/ca-trust/source/anchors/pikit-ca.crt && sudo update-ca-trust</code>
|
||||||
|
<button class="copy" data-target="fedoraCmd">Copy</button>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>BSD (FreeBSD / OpenBSD)</summary>
|
||||||
|
<code id="bsdCmd">fetch -o /tmp/pikit-ca.crt http://pikit.local/assets/pikit-ca.crt && sudo install -m644 /tmp/pikit-ca.crt /usr/local/share/certs/pikit-ca.crt && sudo certctl rehash</code>
|
||||||
|
<button class="copy" data-target="bsdCmd">Copy</button>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="footnote">Once trusted, this page will auto-forward you to the secure dashboard.</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const target = `https://${location.hostname}`;
|
||||||
|
const hasCookie = document.cookie.includes("pikit_https_ok=1");
|
||||||
|
|
||||||
|
document.getElementById("continueBtn").addEventListener("click", () => {
|
||||||
|
window.location = target;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".copy").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const id = btn.dataset.target;
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const text = el.textContent.trim();
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
btn.textContent = "Copied";
|
||||||
|
setTimeout(() => (btn.textContent = "Copy"), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
btn.textContent = "Failed";
|
||||||
|
setTimeout(() => (btn.textContent = "Copy"), 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accordion: keep only one platform section open at a time
|
||||||
|
const detailBlocks = Array.from(document.querySelectorAll("details"));
|
||||||
|
detailBlocks.forEach((d) => {
|
||||||
|
d.addEventListener("toggle", () => {
|
||||||
|
if (!d.open) return;
|
||||||
|
detailBlocks.forEach((other) => {
|
||||||
|
if (other !== d) other.removeAttribute("open");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasCookie) {
|
||||||
|
window.location = target;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
194
pikit-web/onboarding/style.css
Normal file
194
pikit-web/onboarding/style.css
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #070b15;
|
||||||
|
--panel: rgba(13, 18, 28, 0.9);
|
||||||
|
--panel-2: rgba(18, 26, 40, 0.7);
|
||||||
|
--text: #e9f0ff;
|
||||||
|
--muted: #9bb0ca;
|
||||||
|
--accent: #44d392;
|
||||||
|
--accent-2: #6cc9ff;
|
||||||
|
--border: #1b2538;
|
||||||
|
--shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
|
||||||
|
--glow: 0 20px 70px rgba(68, 211, 146, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px 22px;
|
||||||
|
background: radial-gradient(140% 140% at 12% 18%, #0f1625, #080c14 58%);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "DM Sans", "Inter", system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
max-width: 920px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 28px 30px 32px;
|
||||||
|
box-shadow: var(--shadow), var(--glow);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 10px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 14px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge .dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 18px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a.ghost {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: linear-gradient(135deg, var(--accent), #2dbb7b);
|
||||||
|
color: #041008;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 10px 30px rgba(68, 211, 146, 0.22);
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
a.ghost:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 12px 36px rgba(68, 211, 146, 0.3);
|
||||||
|
filter: brightness(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost,
|
||||||
|
a.ghost {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.copy {
|
||||||
|
margin-left: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 7px 11px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
padding: 14px 15px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 6px 0 6px 18px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
display: block;
|
||||||
|
background: #0b111c;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote {
|
||||||
|
font-size: 0.93rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user