Add diagnostics logging (RAM), UI viewer, and toggles
This commit is contained in:
150
pikit-api.py
150
pikit-api.py
@@ -8,6 +8,8 @@ import urllib.parse
|
|||||||
import fcntl
|
import fcntl
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import json as jsonlib
|
import json as jsonlib
|
||||||
|
import io
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
HOST = "127.0.0.1"
|
HOST = "127.0.0.1"
|
||||||
PORT = 4000
|
PORT = 4000
|
||||||
@@ -49,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)
|
||||||
@@ -80,12 +175,17 @@ def normalize_path(path: str | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
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):
|
||||||
@@ -677,6 +777,7 @@ 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)
|
||||||
@@ -696,10 +797,12 @@ def check_for_update():
|
|||||||
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
|
||||||
@@ -729,6 +832,7 @@ 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:
|
||||||
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
|
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
|
||||||
@@ -763,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
|
||||||
@@ -774,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)
|
||||||
@@ -804,14 +910,17 @@ 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
|
||||||
backup = choose_rollback_backup()
|
backup = choose_rollback_backup()
|
||||||
if backup:
|
if backup:
|
||||||
@@ -820,9 +929,11 @@ def apply_update_stub():
|
|||||||
state["current_version"] = read_current_version()
|
state["current_version"] = read_current_version()
|
||||||
state["message"] += f" (rolled back to backup {backup.name})"
|
state["message"] += f" (rolled back to backup {backup.name})"
|
||||||
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
|
||||||
@@ -844,6 +955,7 @@ 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)
|
||||||
|
diag_log("info", "Rollback started")
|
||||||
backup = choose_rollback_backup()
|
backup = choose_rollback_backup()
|
||||||
if not backup:
|
if not backup:
|
||||||
state["status"] = "error"
|
state["status"] = "error"
|
||||||
@@ -861,9 +973,11 @@ def rollback_update_stub():
|
|||||||
ver = get_backup_version(backup)
|
ver = get_backup_version(backup)
|
||||||
suffix = f" (version {ver})" if ver else ""
|
suffix = f" (version {ver})" if ver else ""
|
||||||
state["message"] = f"Rolled back to backup {backup.name}{suffix}"
|
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)
|
||||||
@@ -1090,6 +1204,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self._send(200, {"text": text})
|
return self._send(200, {"text": text})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._send(500, {"error": str(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"})
|
||||||
|
|
||||||
@@ -1100,6 +1218,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"})
|
||||||
@@ -1109,14 +1228,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()
|
||||||
@@ -1140,6 +1262,7 @@ 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"):
|
if self.path.startswith("/api/update/channel"):
|
||||||
chan = payload.get("channel", "dev")
|
chan = payload.get("channel", "dev")
|
||||||
@@ -1148,7 +1271,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
state = load_update_state()
|
state = load_update_state()
|
||||||
state["channel"] = chan
|
state["channel"] = chan
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
|
diag_log("info", "Release channel set", {"channel": chan})
|
||||||
return self._send(200, state)
|
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))
|
||||||
@@ -1182,6 +1317,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))
|
||||||
@@ -1195,6 +1331,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))
|
||||||
@@ -1272,6 +1409,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"})
|
||||||
|
|
||||||
|
|||||||
@@ -117,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",
|
||||||
|
});
|
||||||
|
|||||||
167
pikit-web/assets/diaglog.js
Normal file
167
pikit-web/assets/diaglog.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
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 } =
|
||||||
|
elements;
|
||||||
|
|
||||||
|
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";
|
||||||
|
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() {
|
||||||
|
try {
|
||||||
|
const entries = await syncState();
|
||||||
|
render(entries);
|
||||||
|
toast?.("Diagnostics refreshed", "success");
|
||||||
|
} catch (e) {
|
||||||
|
toast?.(e.error || "Failed to load diagnostics", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enableToggle?.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
debugToggle?.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshBtn?.addEventListener("click", refresh);
|
||||||
|
|
||||||
|
clearBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await clearDiagLog();
|
||||||
|
uiBuffer.length = 0;
|
||||||
|
appendUi("info", "Cleared diagnostics");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast?.(e.error || "Failed to clear log", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
return {
|
||||||
|
logUi,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ 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=20251213g";
|
import { initReleaseUI } from "./releases.js?v=20251213h";
|
||||||
|
import { initDiagUI, logUi } from "./diaglog.js?v=20251213h";
|
||||||
|
|
||||||
const servicesGrid = document.getElementById("servicesGrid");
|
const servicesGrid = document.getElementById("servicesGrid");
|
||||||
const heroStats = document.getElementById("heroStats");
|
const heroStats = document.getElementById("heroStats");
|
||||||
@@ -97,6 +98,14 @@ const changelogModal = document.getElementById("changelogModal");
|
|||||||
const changelogTitle = document.getElementById("changelogTitle");
|
const changelogTitle = document.getElementById("changelogTitle");
|
||||||
const changelogBody = document.getElementById("changelogBody");
|
const changelogBody = document.getElementById("changelogBody");
|
||||||
const changelogClose = document.getElementById("changelogClose");
|
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 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";
|
||||||
@@ -495,6 +504,7 @@ function main() {
|
|||||||
showBusy,
|
showBusy,
|
||||||
hideBusy,
|
hideBusy,
|
||||||
confirmAction,
|
confirmAction,
|
||||||
|
logUi,
|
||||||
});
|
});
|
||||||
loadToastSettings();
|
loadToastSettings();
|
||||||
|
|
||||||
@@ -558,6 +568,23 @@ function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
initDiagUI({
|
||||||
|
elements: {
|
||||||
|
enableToggle: diagEnableToggle,
|
||||||
|
debugToggle: diagDebugToggle,
|
||||||
|
refreshBtn: diagRefreshBtn,
|
||||||
|
clearBtn: diagClearBtn,
|
||||||
|
copyBtn: diagCopyBtn,
|
||||||
|
downloadBtn: diagDownloadBtn,
|
||||||
|
logBox: diagLogBox,
|
||||||
|
statusEl: diagStatus,
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function shorten(text, max = 90) {
|
|||||||
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction }) {
|
export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, logUi = () => {} }) {
|
||||||
const releaseFlagTop = document.getElementById("releaseFlagTop");
|
const releaseFlagTop = document.getElementById("releaseFlagTop");
|
||||||
const releaseBtn = document.getElementById("releaseBtn");
|
const releaseBtn = document.getElementById("releaseBtn");
|
||||||
const releaseModal = document.getElementById("releaseModal");
|
const releaseModal = document.getElementById("releaseModal");
|
||||||
@@ -236,6 +236,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
|||||||
releaseCheckBtn?.addEventListener("click", async () => {
|
releaseCheckBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
logRelease("Checking for updates…");
|
logRelease("Checking for updates…");
|
||||||
|
logUi("Update check requested");
|
||||||
await checkRelease();
|
await checkRelease();
|
||||||
await loadReleaseStatus(true);
|
await loadReleaseStatus(true);
|
||||||
const state = window.__lastReleaseState || {};
|
const state = window.__lastReleaseState || {};
|
||||||
@@ -254,6 +255,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
|||||||
releaseApplyBtn?.addEventListener("click", async () => {
|
releaseApplyBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
lastReleaseToastKey = null;
|
lastReleaseToastKey = null;
|
||||||
|
logUi("Update apply requested");
|
||||||
const state = window.__lastReleaseState || {};
|
const state = window.__lastReleaseState || {};
|
||||||
const { current_version, latest_version } = state;
|
const { current_version, latest_version } = state;
|
||||||
const sameVersion =
|
const sameVersion =
|
||||||
@@ -287,6 +289,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
|||||||
releaseRollbackBtn?.addEventListener("click", async () => {
|
releaseRollbackBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
lastReleaseToastKey = null;
|
lastReleaseToastKey = null;
|
||||||
|
logUi("Rollback requested");
|
||||||
releaseBusyActive = true;
|
releaseBusyActive = true;
|
||||||
showBusy("Rolling back…", "Restoring previous backup.");
|
showBusy("Rolling back…", "Restoring previous backup.");
|
||||||
logRelease("Starting rollback…");
|
logRelease("Starting rollback…");
|
||||||
|
|||||||
@@ -522,6 +522,36 @@
|
|||||||
</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, then refresh/copy/download the log. Logs reset on reboot or clear.
|
||||||
|
</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 wrap gap">
|
||||||
|
<button id="diagRefreshBtn" class="ghost">Refresh log</button>
|
||||||
|
<button id="diagClearBtn" class="ghost">Clear</button>
|
||||||
|
<button id="diagCopyBtn" class="ghost">Copy</button>
|
||||||
|
<button id="diagDownloadBtn" class="ghost">Download</button>
|
||||||
|
<span id="diagStatus" class="hint quiet"></span>
|
||||||
|
</div>
|
||||||
|
<pre id="diagLogBox" class="log-box" aria-live="polite"></pre>
|
||||||
|
<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
|
||||||
@@ -726,7 +756,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="assets/main.js?v=20251213g"></script>
|
<script type="module" src="assets/main.js?v=20251213h"></script>
|
||||||
<div id="toastContainer" class="toast-container"></div>
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user